This commit is contained in:
seb
2026-09-23 08:16:14 +02:00
parent 49057dc0d6
commit c94ce20b7a
7 changed files with 306 additions and 437 deletions

View File

@@ -16,13 +16,8 @@ import {
* @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
*/
@@ -32,25 +27,117 @@ const initialState = {
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
let categoryAutomat;
// ─── Pure helpers ──────────────────────────────────────────────────────────────
// Registry of active per-route category view automats
const categoryViewInstances = new Set();
// Reverse index for O(1) parentId lookup to target route automat
const categoryIdToRouteName = new Map();
function articlesForCategory(state, categoryId) {
if (String(categoryId) === String(state.rootCategoryId)) {
return state.rootArticles;
/**
* Loader for category view automat: fetches articles for categoryId.
* Promise only resolves when all articles are loaded.
*/
async function fetchCategoryArticles(automat) {
try {
const res = await categoryApi.getArticles(automat.state.categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
return { articles, loaded: true, loading: false, error: null };
} catch (err) {
const errorMsg = err?.message ?? String(err);
automat.setState({ loading: false, loaded: false, error: errorMsg });
throw err;
}
return [];
}
/**
* Get or create a per-route category view automat for the given slug and categoryId.
* Returns null if categoryId is null.
*
* @param {{ slugPath?: string, categoryId: number|string|null }} param0
* @returns {Automat|null}
*/
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
if (categoryId == null) return null;
const cleanSlug = (slugPath ?? '').replace(/^\/+|\/+$/g, '');
const name = cleanSlug === '' ? 'category-view:root' : `category-view:${cleanSlug}`;
const existing = Automat.get(name);
if (existing) {
if (String(existing.state?.categoryId) === String(categoryId)) {
categoryViewInstances.add(existing);
categoryIdToRouteName.set(String(categoryId), name);
return existing;
}
// Stale category ID guard: ID changed, dispose old automat
try {
existing.dispose();
} catch {}
categoryViewInstances.delete(existing);
}
const automat = new Automat(
{
slug: cleanSlug,
categoryId,
articles: [],
loaded: false,
loading: true,
error: null,
},
(inst) => ({
reload: () => inst.setDirty({ keep: true, now: true }),
retry: () => inst.setDirty({ keep: false, now: true }),
}),
{
name,
loader: fetchCategoryArticles,
}
);
automat.reload = () => automat.setDirty({ keep: true, now: true });
automat.retry = () => automat.setDirty({ keep: false, now: true });
categoryViewInstances.add(automat);
categoryIdToRouteName.set(String(categoryId), name);
return automat;
}
/**
* Invalidate all active category view automats by triggering background reload.
*/
export function invalidateAllCategoryViews() {
for (const automat of categoryViewInstances) {
if (typeof automat.reload === 'function') {
automat.reload().catch((err) => {
console.error('[categoryAutomat] reload view failed', err);
});
} else if (typeof automat.actions?.reload === 'function') {
automat.actions.reload().catch((err) => {
console.error('[categoryAutomat] reload view failed', err);
});
}
}
}
/**
* Dispose all category view automats and clear registries (e.g. on logout or company switch).
*/
export function disposeAllCategoryViewAutomats() {
for (const automat of categoryViewInstances) {
try {
automat.dispose();
} catch (e) {
console.error('[categoryAutomat] dispose view failed', e);
}
}
categoryViewInstances.clear();
categoryIdToRouteName.clear();
}
// ─── Actions ───────────────────────────────────────────────────────────────────
@@ -73,11 +160,6 @@ const actions = {
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) {
@@ -89,62 +171,6 @@ const actions = {
}
},
/**
* 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) });
@@ -192,17 +218,17 @@ const actions = {
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 (created && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId));
if (routeName) {
const routeAutomat = Automat.get(routeName);
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
routeAutomat.setState({
articles: [...routeAutomat.state.articles, created],
});
}
}
}
if (String(created.parentId) === String(rootCategoryId)) {
patch.rootArticles = [...rootArticles, created];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
@@ -218,19 +244,17 @@ const actions = {
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';
if (created.length > 0 && parentId != null) {
const routeName = categoryIdToRouteName.get(String(parentId));
if (routeName) {
const routeAutomat = Automat.get(routeName);
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
routeAutomat.setState({
articles: [...routeAutomat.state.articles, ...created],
});
}
}
}
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;
},
@@ -247,14 +271,9 @@ const actions = {
const res = await categoryApi.updateName(categoryId, trimmed);
const updated = normalizeCategoryNode(res.category);
const key = String(updated.id);
const { categories, rootArticles } = categoryAutomat.state;
const { categories } = 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;
},
@@ -264,7 +283,7 @@ const actions = {
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
*/
async reorderCategories({ parentId, categoryIds }) {
const targetParentId = parentId != null ? parentId : categoryAutomat.state.currentCategoryId;
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
const res = await categoryApi.reorder(targetParentId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(targetParentId);
@@ -304,18 +323,11 @@ const actions = {
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const { categories } = 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);
categoryAutomat.setState({ categories: next });
return categoryId;
},
@@ -372,55 +384,17 @@ const actions = {
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from both article lists (used after article deletion). */
/** Remove an article from all live category view 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;
if (!sku) return;
for (const automat of categoryViewInstances) {
const articles = automat.state?.articles;
if (Array.isArray(articles) && articles.some((r) => r.sku === sku)) {
automat.setState({
articles: articles.filter((r) => r.sku !== sku),
});
}
}
categoryAutomat.setState({
articles: nextArticles,
articlesTotal: nextArticlesTotal,
rootArticles: nextRoot,
});
},
/**
@@ -445,6 +419,7 @@ const actions = {
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
categoryAutomat.setState(initialState);
},
};
@@ -464,6 +439,7 @@ companyAutomat.subscribe((company) => {
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
disposeAllCategoryViewAutomats();
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {