no redux
This commit is contained in:
490
src/automats/categoryAutomat.js
Normal file
490
src/automats/categoryAutomat.js
Normal file
@@ -0,0 +1,490 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import { category as categoryApi, article as articleApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
import {
|
||||
parseCategoryTreeResponse,
|
||||
normalizeCategoryNode,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
ROOT_CATEGORY_NAME,
|
||||
} from '../utils/categoryUtils';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CategoryState
|
||||
* @property {number|string|null} companyId
|
||||
* @property {string|null} languageId
|
||||
* @property {Record<string,object>|null} categories id → node map
|
||||
* @property {number|string|null} rootCategoryId
|
||||
* @property {Array<object>} rootArticles
|
||||
* @property {number|string|null} currentCategoryId
|
||||
* @property {Array<object>} articles
|
||||
* @property {number} articlesTotal
|
||||
* @property {boolean} renameMode
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {CategoryState} */
|
||||
const initialState = {
|
||||
companyId: null,
|
||||
languageId: null,
|
||||
categories: null,
|
||||
rootCategoryId: null,
|
||||
rootArticles: [],
|
||||
currentCategoryId: null,
|
||||
articles: [],
|
||||
articlesTotal: 0,
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
articlesStatus: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let categoryAutomat;
|
||||
|
||||
// ─── Pure helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function articlesForCategory(state, categoryId) {
|
||||
if (String(categoryId) === String(state.rootCategoryId)) {
|
||||
return state.rootArticles;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const actions = {
|
||||
/** Load the category tree for the currently selected company. */
|
||||
async loadTree() {
|
||||
const company = companyAutomat.state.current;
|
||||
if (!company) return;
|
||||
const { id: companyId, rootCategoryId: companyRootId } = company;
|
||||
if (!companyRootId) return;
|
||||
|
||||
categoryAutomat.setState({ treeStatus: 'loading', error: null });
|
||||
try {
|
||||
const data = await categoryApi.getTree();
|
||||
const parsed = parseCategoryTreeResponse(data, companyRootId);
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'succeeded',
|
||||
companyId,
|
||||
languageId: parsed.languageId,
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
currentCategoryId: parsed.rootCategoryId,
|
||||
rootArticles: parsed.rootArticles,
|
||||
articles: parsed.rootArticles,
|
||||
articlesTotal: parsed.rootArticles.length,
|
||||
articlesStatus: 'succeeded',
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'failed',
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load articles for a non-root category.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async loadArticles(categoryId) {
|
||||
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
|
||||
try {
|
||||
const res = await categoryApi.getArticles(categoryId);
|
||||
const articles = Array.isArray(res.articles) ? res.articles : [];
|
||||
categoryAutomat.setState({
|
||||
articlesStatus: 'succeeded',
|
||||
articles,
|
||||
articlesTotal: articles.length,
|
||||
});
|
||||
} catch (err) {
|
||||
categoryAutomat.setState({
|
||||
articlesStatus: 'failed',
|
||||
articles: [],
|
||||
articlesTotal: 0,
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** Navigate to a category by id. */
|
||||
navigateTo(categoryId) {
|
||||
const { rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
const isRoot = String(categoryId) === String(rootCategoryId);
|
||||
categoryAutomat.setState({
|
||||
currentCategoryId: categoryId,
|
||||
error: null,
|
||||
articles: isRoot ? rootArticles : [],
|
||||
articlesTotal: isRoot ? rootArticles.length : 0,
|
||||
articlesStatus: isRoot ? 'succeeded' : 'idle',
|
||||
});
|
||||
},
|
||||
|
||||
/** Navigate up to the parent category. */
|
||||
navigateBack() {
|
||||
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
const current = categories?.[String(currentCategoryId)];
|
||||
const parentId = current?.parentId;
|
||||
if (parentId == null || parentId === 0) return;
|
||||
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
|
||||
const isRoot = String(parentId) === String(rootCategoryId);
|
||||
categoryAutomat.setState({
|
||||
currentCategoryId: parentId,
|
||||
error: null,
|
||||
articles: isRoot ? rootArticles : [],
|
||||
articlesTotal: isRoot ? rootArticles.length : 0,
|
||||
articlesStatus: isRoot ? 'succeeded' : 'idle',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggle rename/edit mode for the category grid. */
|
||||
setRenameMode(enabled) {
|
||||
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
||||
},
|
||||
|
||||
// ─── Category mutations ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a single category.
|
||||
* @param {{ parentId: number|string, name: string }} param0
|
||||
*/
|
||||
async createCategory({ parentId, name }) {
|
||||
const res = await categoryApi.create({ parentId, name });
|
||||
const node = normalizeCategoryNode(res.category);
|
||||
const { categories } = categoryAutomat.state;
|
||||
categoryAutomat.setState({
|
||||
categories: { ...(categories ?? {}), [String(node.id)]: node },
|
||||
});
|
||||
return node;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create multiple categories under the same parent.
|
||||
* @param {{ parentId: number|string, names: string[] }} param0
|
||||
*/
|
||||
async createCategories({ parentId, names }) {
|
||||
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
|
||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||
const created = [];
|
||||
for (const name of list) {
|
||||
const res = await categoryApi.create({ parentId, name });
|
||||
created.push(normalizeCategoryNode(res.category));
|
||||
}
|
||||
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
|
||||
categoryAutomat.setState({
|
||||
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch },
|
||||
});
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a single article.
|
||||
* @param {{ parentId: number|string, name: string }} param0
|
||||
*/
|
||||
async createArticle({ parentId, name }) {
|
||||
const res = await articleApi.create({ parentId, name });
|
||||
const created = res.article;
|
||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
||||
const patch = {};
|
||||
if (String(created.parentId) === String(currentCategoryId)) {
|
||||
patch.articles = [...articles, created];
|
||||
patch.articlesTotal = patch.articles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
}
|
||||
if (String(created.parentId) === String(rootCategoryId)) {
|
||||
patch.rootArticles = [...rootArticles, created];
|
||||
}
|
||||
if (Object.keys(patch).length) categoryAutomat.setState(patch);
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create multiple articles under the same parent.
|
||||
* @param {{ parentId: number|string, names: string[] }} param0
|
||||
*/
|
||||
async createArticles({ parentId, names }) {
|
||||
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
|
||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||
const created = [];
|
||||
for (const name of list) {
|
||||
const res = await articleApi.create({ parentId, name });
|
||||
if (res.article) created.push(res.article);
|
||||
}
|
||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
||||
const patch = {};
|
||||
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
|
||||
if (forCurrent.length > 0) {
|
||||
patch.articles = [...articles, ...forCurrent];
|
||||
patch.articlesTotal = patch.articles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
}
|
||||
const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId));
|
||||
if (forRoot.length > 0) {
|
||||
patch.rootArticles = [...rootArticles, ...forRoot];
|
||||
}
|
||||
if (Object.keys(patch).length) categoryAutomat.setState(patch);
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename a category.
|
||||
* @param {{ categoryId: number|string, name: string }} param0
|
||||
*/
|
||||
async renameCategory({ categoryId, name }) {
|
||||
const trimmed = String(name ?? '').trim();
|
||||
if (!trimmed) throw new Error('Name required');
|
||||
const res = await categoryApi.updateName(categoryId, trimmed);
|
||||
const updated = normalizeCategoryNode(res.category);
|
||||
const key = String(updated.id);
|
||||
const { categories, rootArticles } = categoryAutomat.state;
|
||||
categoryAutomat.setState({
|
||||
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
|
||||
rootArticles: rootArticles.map((row) =>
|
||||
row?.categoryId != null && String(row.categoryId) === key
|
||||
? { ...row, name: updated.name }
|
||||
: row,
|
||||
),
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder sibling categories.
|
||||
* @param {{ categoryIds: number[] }} param0
|
||||
*/
|
||||
async reorderCategories({ categoryIds }) {
|
||||
const { currentCategoryId, categories } = categoryAutomat.state;
|
||||
const res = await categoryApi.reorder(currentCategoryId, categoryIds);
|
||||
const ordered = res.categoryIds;
|
||||
const parentKey = String(currentCategoryId);
|
||||
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
|
||||
const patched = { ...categories };
|
||||
for (const node of Object.values(patched)) {
|
||||
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
|
||||
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
|
||||
}
|
||||
}
|
||||
categoryAutomat.setState({ categories: patched });
|
||||
return ordered;
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a category under a new parent.
|
||||
* @param {{ categoryId: number|string, parentId: number|string }} param0
|
||||
*/
|
||||
async moveCategory({ categoryId, parentId }) {
|
||||
const res = await categoryApi.move(categoryId, parentId);
|
||||
const moved = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories || !moved) return;
|
||||
const key = String(moved.id);
|
||||
categoryAutomat.setState({
|
||||
categories: {
|
||||
...categories,
|
||||
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
|
||||
},
|
||||
});
|
||||
return moved;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a category by id.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async deleteCategory(categoryId) {
|
||||
await categoryApi.delete(categoryId);
|
||||
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
if (!categories) return;
|
||||
const next = { ...categories };
|
||||
delete next[String(categoryId)];
|
||||
const patch = { categories: next };
|
||||
if (String(currentCategoryId) === String(categoryId)) {
|
||||
patch.currentCategoryId = rootCategoryId;
|
||||
patch.articles = rootArticles;
|
||||
patch.articlesTotal = rootArticles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
}
|
||||
categoryAutomat.setState(patch);
|
||||
return categoryId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the POS flag on a category.
|
||||
* @param {{ categoryId: number|string, pos: boolean }} param0
|
||||
*/
|
||||
async toggleCategoryPos({ categoryId, pos }) {
|
||||
const res = await categoryApi.updatePos(categoryId, pos);
|
||||
const updated = normalizeCategoryNode(res.category);
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return;
|
||||
const key = String(updated.id);
|
||||
categoryAutomat.setState({
|
||||
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } },
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload an image for a category.
|
||||
* @param {{ categoryId: number|string, file: Blob }} param0
|
||||
*/
|
||||
async uploadCategoryImage({ categoryId, file }) {
|
||||
const res = await categoryApi.uploadImage(categoryId, file);
|
||||
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
categoryAutomat.setState({
|
||||
categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } },
|
||||
});
|
||||
return res.category;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the image for a category.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async deleteCategoryImage(categoryId) {
|
||||
const res = await categoryApi.deleteImage(categoryId);
|
||||
const { id } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
categoryAutomat.setState({
|
||||
categories: {
|
||||
...categories,
|
||||
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
|
||||
},
|
||||
});
|
||||
return res.category;
|
||||
},
|
||||
|
||||
// ─── Local state patches ───────────────────────────────────────────────────
|
||||
|
||||
/** Remove an article from both article lists (used after article deletion). */
|
||||
removeArticleBySku(sku) {
|
||||
categoryAutomat.setState({
|
||||
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
|
||||
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
|
||||
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Patch article fields and category membership in local lists.
|
||||
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
|
||||
*/
|
||||
patchArticleInLists({ sku, categoryIds, ...fields }) {
|
||||
if (sku == null) return;
|
||||
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
|
||||
|
||||
const patchList = (list) =>
|
||||
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
|
||||
|
||||
let nextArticles = patchList(articles);
|
||||
let nextRoot = patchList(rootArticles);
|
||||
let nextArticlesTotal = nextArticles.length;
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
const newIds = categoryIds ?? [];
|
||||
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
|
||||
|
||||
if (rootCategoryId != null) {
|
||||
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
|
||||
const wasInRoot = nextRoot.some((r) => r.sku === sku);
|
||||
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
|
||||
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
|
||||
}
|
||||
|
||||
if (currentCategoryId != null) {
|
||||
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
|
||||
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
|
||||
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
|
||||
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
|
||||
nextArticlesTotal = nextArticles.length;
|
||||
}
|
||||
}
|
||||
|
||||
categoryAutomat.setState({
|
||||
articles: nextArticles,
|
||||
articlesTotal: nextArticlesTotal,
|
||||
rootArticles: nextRoot,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Patch posterminalIds on a category node.
|
||||
* @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0
|
||||
*/
|
||||
patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) {
|
||||
const { categories } = categoryAutomat.state;
|
||||
const cat = categories?.[String(categoryId)];
|
||||
if (!cat) return;
|
||||
const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : [];
|
||||
const next = enabled
|
||||
? current.includes(terminalId) ? current : [...current, terminalId]
|
||||
: current.filter((id) => id !== terminalId);
|
||||
categoryAutomat.setState({
|
||||
categories: {
|
||||
...categories,
|
||||
[String(categoryId)]: { ...cat, posterminalIds: next },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Reset state to idle (logout / company switch). */
|
||||
reset() {
|
||||
categoryAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global category tree state container.
|
||||
* Loads automatically when companyAutomat has a company with a rootCategoryId.
|
||||
*
|
||||
* @type {Automat<CategoryState>}
|
||||
*/
|
||||
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
|
||||
|
||||
// Load tree whenever the company data changes (or first becomes available)
|
||||
let _lastCompanyId = null;
|
||||
companyAutomat.subscribe((company) => {
|
||||
const id = company.current?.id ?? null;
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
_lastCompanyId = id;
|
||||
void categoryAutomat.actions.loadTree();
|
||||
}
|
||||
if (!id && _lastCompanyId) {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Reset tree on logout
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-export pure helpers for consumers that previously imported from categorySlice
|
||||
export {
|
||||
ROOT_CATEGORY_NAME,
|
||||
parseCategoryTreeResponse,
|
||||
normalizeCategoryNode,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
} from '../utils/categoryUtils';
|
||||
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
|
||||
|
||||
export default categoryAutomat;
|
||||
Reference in New Issue
Block a user