549 lines
19 KiB
JavaScript
549 lines
19 KiB
JavaScript
/**
|
|
* @file categoryAutomat.js
|
|
* @description Owns the hierarchical category tree (id -> node map), the root
|
|
* category id, breadcrumbs helpers, and all category/article mutations.
|
|
*
|
|
* Per-category article views (`categoryViews`) live in `categoryViewAutomat.js`
|
|
* and attach to this tree automat via `attachCategoryTree()`, keeping the
|
|
* dependency one-directional (views -> tree).
|
|
*
|
|
* Upstream Triggers:
|
|
* - `companyAutomat`: Subscribes to company changes — triggers `actions.loadTree()`
|
|
* whenever `company.current.id` or `rootCategoryId` changes/becomes available;
|
|
* resets when company is unset.
|
|
* - `authAutomat`: Subscribes to auth state changes — triggers `actions.reset()` on `'anon'`.
|
|
* - `sseAutomat`: Calls `categoryAutomat.actions.loadTree()` and
|
|
* `invalidateAllCategoryViews()` upon receiving a `'category'` SSE event.
|
|
* - UI / Routes: Category tree operations (`createCategory`, `createArticle`,
|
|
* `deleteCategory`, `moveCategory`, `renameCategory`, `reorderCategories`, etc.).
|
|
*
|
|
* Downstream Triggers:
|
|
* - `categoryViewAutomat`: `loadTree()` calls `loadAllCategoryViews()` once
|
|
* `treeStatus === 'succeeded'`, so every category's articles are already cached
|
|
* when a route mounts. Mutations patch the loaded views through the same module.
|
|
* - `articleIndexAutomat`: Derives its client-side search index from the union of
|
|
* the loaded category views, so the index costs no requests of its own and
|
|
* `GET /api/articleIndex` is no longer used.
|
|
*/
|
|
|
|
import { Automat } from 'react-automat';
|
|
import { category as categoryApi, article as articleApi } from '../api';
|
|
import authAutomat from './authAutomat';
|
|
import companyAutomat from './companyAutomat';
|
|
import sseAutomat from './sseAutomat';
|
|
import {
|
|
attachCategoryTree,
|
|
categoryViews,
|
|
loadAllCategoryViews,
|
|
disposeAllCategoryViewAutomats,
|
|
refreshCategoryViewChildren,
|
|
appendArticlesToCategoryViews,
|
|
updateArticleInCategoryViews,
|
|
removeArticleBySku,
|
|
waitForCategoryTree,
|
|
} from './categoryViewAutomat';
|
|
import {
|
|
parseCategoryTreeResponse,
|
|
normalizeCategoryNode,
|
|
cleanCategorySlugPath,
|
|
getCategoryChildren,
|
|
getCategoryDescendantIds,
|
|
} 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 {number} articleCount Total parent articles in tree
|
|
* @property {boolean} renameMode
|
|
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
|
* @property {string|null} error
|
|
* @property {boolean} viewsLoading Whether the post-tree view preload is running.
|
|
*/
|
|
|
|
/** @type {CategoryState} */
|
|
const initialState = {
|
|
companyId: null,
|
|
languageId: null,
|
|
categories: null,
|
|
rootCategoryId: null,
|
|
articleCount: 0,
|
|
renameMode: false,
|
|
treeStatus: 'idle',
|
|
error: null,
|
|
viewsLoading: false,
|
|
};
|
|
|
|
let categoryAutomat;
|
|
let _treeLoadingPromise = null;
|
|
|
|
/**
|
|
* Returns the parsed category map and root id, or `null` while the tree is
|
|
* still loading. Replaces the former `StorageManager.getCategoryState()`.
|
|
*
|
|
* @returns {{ categories: Record<string,object>, rootCategoryId: unknown }|null}
|
|
*/
|
|
export function getCategoryState() {
|
|
const state = categoryAutomat.state;
|
|
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
|
|
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
|
}
|
|
|
|
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
|
|
|
|
/**
|
|
* Resolves once the category tree has loaded.
|
|
*
|
|
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
|
|
*/
|
|
export function whenCategoryTreeReady() {
|
|
return waitForCategoryTree();
|
|
}
|
|
|
|
// ─── Actions ───────────────────────────────────────────────────────────────────
|
|
|
|
const actions = {
|
|
/** Load the category tree for the currently selected company. */
|
|
async loadTree() {
|
|
let company = companyAutomat.state.current;
|
|
if (!company && companyAutomat.state.status === 'idle') {
|
|
await companyAutomat.actions.load();
|
|
company = companyAutomat.state.current;
|
|
}
|
|
if (!company) return;
|
|
const { id: companyId, rootCategoryId: companyRootId } = company;
|
|
if (!companyRootId) return;
|
|
|
|
if (_treeLoadingPromise) return _treeLoadingPromise;
|
|
|
|
categoryAutomat.setState({ treeStatus: 'loading', error: null });
|
|
_treeLoadingPromise = (async () => {
|
|
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,
|
|
articleCount: parsed.articleCount,
|
|
error: null,
|
|
viewsLoading: true,
|
|
});
|
|
refreshCategoryViewChildren(parsed.categories);
|
|
|
|
// The tree is the source of truth for every category view, so warm all
|
|
// of them now: routes mount from cache instead of waiting on a fetch.
|
|
// A failing category must not fail the whole tree load.
|
|
const { failed } = await loadAllCategoryViews();
|
|
if (failed.length) {
|
|
console.warn(
|
|
`${failed.length} Kategorie(n) konnten nicht vorab geladen werden.`,
|
|
failed,
|
|
);
|
|
}
|
|
categoryAutomat.setState({ viewsLoading: false });
|
|
} catch (err) {
|
|
categoryAutomat.setState({
|
|
treeStatus: 'failed',
|
|
viewsLoading: false,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
throw err;
|
|
}
|
|
})().finally(() => {
|
|
_treeLoadingPromise = null;
|
|
});
|
|
|
|
return _treeLoadingPromise;
|
|
},
|
|
|
|
/** Toggle rename/edit mode for the category grid. */
|
|
setRenameMode(enabled) {
|
|
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
|
},
|
|
|
|
/**
|
|
* (Re-)loads every category view of the current tree on demand.
|
|
*
|
|
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
|
|
*/
|
|
async loadAllCategoryViews() {
|
|
return loadAllCategoryViews();
|
|
},
|
|
|
|
// ─── 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;
|
|
const updated = { ...(categories ?? {}), [String(node.id)]: node };
|
|
categoryAutomat.setState({ categories: updated });
|
|
refreshCategoryViewChildren(updated);
|
|
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]));
|
|
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
|
|
categoryAutomat.setState({ categories: updated });
|
|
refreshCategoryViewChildren(updated);
|
|
return created;
|
|
},
|
|
|
|
/**
|
|
* Create a single article.
|
|
* @param {{ parentId: number|string, name: string }} param0
|
|
*/
|
|
async createArticle({ parentId, name }) {
|
|
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
|
const { article: created } = await articleApi.getBySku(sku);
|
|
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
|
|
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 { sku } = await articleApi.create({ categoryId: parentId, name });
|
|
const { article: created1 } = await articleApi.getBySku(sku);
|
|
if (created1) created.push(created1);
|
|
}
|
|
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
|
|
return created;
|
|
},
|
|
|
|
/**
|
|
* Rename a category.
|
|
* @param {{ categoryId: number|string, name: string }} param0
|
|
*/
|
|
async renameCategory({ categoryId, name }) {
|
|
if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) {
|
|
throw new Error('Home-Kategorie kann nicht umbenannt werden.');
|
|
}
|
|
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 } = categoryAutomat.state;
|
|
const nextCategories = categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null;
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
return updated;
|
|
},
|
|
|
|
/**
|
|
* Reorder sibling categories.
|
|
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
|
|
*/
|
|
async reorderCategories({ parentId, categoryIds }) {
|
|
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
|
|
const res = await categoryApi.reorder(targetParentId, categoryIds);
|
|
const ordered = res.categoryIds;
|
|
const parentKey = String(targetParentId);
|
|
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
|
|
const patched = { ...categoryAutomat.state.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 });
|
|
refreshCategoryViewChildren(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);
|
|
const updated = {
|
|
...categories,
|
|
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
|
|
};
|
|
categoryAutomat.setState({
|
|
categories: updated,
|
|
});
|
|
refreshCategoryViewChildren(updated);
|
|
return moved;
|
|
},
|
|
|
|
/**
|
|
* Delete a category by id.
|
|
* @param {number|string} categoryId
|
|
*/
|
|
async deleteCategory(categoryId) {
|
|
await categoryApi.delete(categoryId);
|
|
const { categories } = categoryAutomat.state;
|
|
if (!categories) return;
|
|
const next = { ...categories };
|
|
delete next[String(categoryId)];
|
|
categoryAutomat.setState({ categories: next });
|
|
refreshCategoryViewChildren(next);
|
|
return categoryId;
|
|
},
|
|
|
|
/**
|
|
* Delete a category and all its descendants recursively.
|
|
* Calls DELETE /api/category/:id?recursive=true.
|
|
*
|
|
* @param {number|string} categoryId
|
|
*/
|
|
async deleteCategoryRecursive(categoryId) {
|
|
const { categories, rootCategoryId } = categoryAutomat.state;
|
|
|
|
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
|
|
throw new Error('Root-Kategorie kann nicht gelöscht werden');
|
|
}
|
|
|
|
const removedIds = getCategoryDescendantIds(categories, categoryId);
|
|
|
|
await categoryApi.delete(categoryId, { recursive: true });
|
|
|
|
if (categories) {
|
|
const next = { ...categories };
|
|
for (const id of removedIds) {
|
|
delete next[String(id)];
|
|
}
|
|
categoryAutomat.setState({ categories: next });
|
|
refreshCategoryViewChildren(next);
|
|
}
|
|
|
|
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);
|
|
const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } };
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
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);
|
|
const nextCategories = { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } };
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
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);
|
|
const nextCategories = {
|
|
...categories,
|
|
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
|
|
};
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
return res.category;
|
|
},
|
|
|
|
/**
|
|
* Generate a mosaic image for a category from its articles and subcategories.
|
|
* @param {{ categoryId: number|string, size?: number }} param0
|
|
*/
|
|
async generateCategoryImage({ categoryId, size } = {}) {
|
|
const res = await categoryApi.generateImage(categoryId, { size });
|
|
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
|
const { categories } = categoryAutomat.state;
|
|
if (!categories) return res.category;
|
|
const key = String(id);
|
|
const nextCategories = {
|
|
...categories,
|
|
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
|
|
};
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
return res.category;
|
|
},
|
|
|
|
|
|
// ─── Local state patches ───────────────────────────────────────────────────
|
|
|
|
/** Remove an article from all live category view lists (used after article deletion). */
|
|
removeArticleBySku(sku) {
|
|
removeArticleBySku(sku);
|
|
},
|
|
|
|
/**
|
|
* 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);
|
|
const nextCategories = {
|
|
...categories,
|
|
[String(categoryId)]: { ...cat, posterminalIds: next },
|
|
};
|
|
categoryAutomat.setState({
|
|
categories: nextCategories,
|
|
});
|
|
refreshCategoryViewChildren(nextCategories);
|
|
},
|
|
|
|
/** Invalidate all loaded category views. */
|
|
invalidateAllCategoryViews() {
|
|
invalidateAllCategoryViews();
|
|
},
|
|
|
|
/** Reset state to idle (logout / company switch). */
|
|
reset() {
|
|
disposeAllCategoryViewAutomats();
|
|
_treeLoadingPromise = null;
|
|
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' });
|
|
|
|
// Let the per-category views resolve their slug paths against this tree.
|
|
attachCategoryTree(categoryAutomat);
|
|
|
|
// Keyed view accessors on categoryAutomat:
|
|
categoryAutomat.key = (slugPath) => categoryViews.key(cleanCategorySlugPath(slugPath));
|
|
categoryAutomat.read = (slugPath) => categoryViews.read(cleanCategorySlugPath(slugPath));
|
|
categoryAutomat.load = (slugPath, options) => categoryViews.load(cleanCategorySlugPath(slugPath), options);
|
|
categoryAutomat.views = categoryViews;
|
|
|
|
// Load tree whenever the company data changes (or first becomes available)
|
|
let _lastCompanyId = null;
|
|
function syncCategoryWithCompany(company) {
|
|
const id = company?.current?.id ?? null;
|
|
const rootCategoryId = company?.current?.rootCategoryId ?? null;
|
|
if (id && rootCategoryId && id !== _lastCompanyId) {
|
|
const isCompanySwitch = _lastCompanyId !== null;
|
|
_lastCompanyId = id;
|
|
if (isCompanySwitch) {
|
|
disposeAllCategoryViewAutomats();
|
|
}
|
|
void categoryAutomat.actions.loadTree();
|
|
}
|
|
if (!id && _lastCompanyId) {
|
|
_lastCompanyId = null;
|
|
categoryAutomat.actions.reset();
|
|
}
|
|
}
|
|
|
|
companyAutomat.subscribe(syncCategoryWithCompany);
|
|
syncCategoryWithCompany(companyAutomat.state);
|
|
|
|
// Reset tree on logout
|
|
authAutomat.subscribe((auth) => {
|
|
if (auth.status === 'anon') {
|
|
_lastCompanyId = null;
|
|
categoryAutomat.actions.reset();
|
|
}
|
|
});
|
|
|
|
// Upstream Trigger: sseAutomat
|
|
if (sseAutomat?.actions?.on) {
|
|
sseAutomat.actions.on('category', () => {
|
|
void categoryAutomat.actions.loadTree().catch(() => {});
|
|
invalidateAllCategoryViews();
|
|
});
|
|
}
|
|
|
|
// Re-export pure helpers for consumers that previously imported from categorySlice
|
|
export {
|
|
ROOT_CATEGORY_NAME,
|
|
parseCategoryTreeResponse,
|
|
normalizeCategoryNode,
|
|
getCategoryBreadcrumbs,
|
|
getCategoryChildren,
|
|
cleanCategorySlugPath,
|
|
resolveCategoryIdFromSlugPath,
|
|
} from '../utils/categoryUtils';
|
|
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
|
|
|
|
// Re-export category view automats and actions
|
|
export {
|
|
categoryViews,
|
|
getOrCreateCategoryAutomat,
|
|
invalidateAllCategoryViews,
|
|
disposeAllCategoryViewAutomats,
|
|
loadAllCategoryViews,
|
|
collectLoadedCategoryViewData,
|
|
updateArticleInCategoryViews,
|
|
} from './categoryViewAutomat';
|
|
|
|
export default categoryAutomat;
|