This commit is contained in:
seb
2026-09-23 17:24:33 +02:00
parent 0391e845f0
commit b331f8f535
17 changed files with 2352 additions and 202 deletions

View File

@@ -1,4 +1,4 @@
import { Automat } from 'react-automat';
import { Automat, KeyedAutomat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
@@ -7,6 +7,8 @@ import {
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils';
@@ -34,110 +36,81 @@ const initialState = {
let categoryAutomat;
// 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();
/**
* KeyedAutomat holding per-route category views indexed by slug path.
* For each slug path, loader resolves the categoryId from the category tree,
* fetches articles in that category, and provides childCategories (children of the category).
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// Ensure tree is loaded before resolving slug -> categoryId
if (categoryAutomat.state.treeStatus !== 'succeeded') {
await categoryAutomat.actions.loadTree();
}
const { categories, rootCategoryId } = categoryAutomat.state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug || ROOT_CATEGORY_NAME}“ nicht gefunden.`);
}
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
const childCategories = getCategoryChildren(categories, categoryId);
return {
slug,
categoryId,
articles,
childCategories,
};
},
}
);
/**
* Loader for category view automat: fetches articles for categoryId.
* Promise only resolves when all articles are loaded.
* Updates childCategories on all loaded category view handles when categories tree changes.
*/
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;
function refreshLoadedCategoryViewChildren(categories) {
if (!categories) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && item.data.categoryId != null) {
const childCategories = getCategoryChildren(categories, item.data.categoryId);
categoryViews.key(slug).set({
...item.data,
childCategories,
});
}
}
}
/**
* 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}
* (Backward compatibility helper)
*/
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;
const slug = cleanCategorySlugPath(slugPath);
return categoryViews.key(slug);
}
/**
* 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);
});
}
}
return categoryViews.setDirty(undefined, false, true);
}
/**
* 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();
categoryViews.clear();
}
// ─── Actions ───────────────────────────────────────────────────────────────────
@@ -162,6 +135,7 @@ const actions = {
rootCategoryId: parsed.rootCategoryId,
error: null,
});
refreshLoadedCategoryViewChildren(parsed.categories);
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
@@ -186,9 +160,9 @@ const actions = {
const res = await categoryApi.create({ parentId, name });
const node = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
categoryAutomat.setState({
categories: { ...(categories ?? {}), [String(node.id)]: node },
});
const updated = { ...(categories ?? {}), [String(node.id)]: node };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
return node;
},
@@ -205,9 +179,9 @@ const actions = {
created.push(normalizeCategoryNode(res.category));
}
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
categoryAutomat.setState({
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch },
});
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
return created;
},
@@ -219,12 +193,11 @@ const actions = {
const res = await articleApi.create({ parentId, name });
const created = res.article;
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],
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), created],
});
}
}
@@ -245,12 +218,11 @@ const actions = {
if (res.article) created.push(res.article);
}
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],
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), ...created],
});
}
}
@@ -272,9 +244,11 @@ const actions = {
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: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
return updated;
},
@@ -295,6 +269,7 @@ const actions = {
}
}
categoryAutomat.setState({ categories: patched });
refreshLoadedCategoryViewChildren(patched);
return ordered;
},
@@ -308,12 +283,14 @@ const actions = {
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: {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
},
categories: updated,
});
refreshLoadedCategoryViewChildren(updated);
return moved;
},
@@ -328,6 +305,7 @@ const actions = {
const next = { ...categories };
delete next[String(categoryId)];
categoryAutomat.setState({ categories: next });
refreshLoadedCategoryViewChildren(next);
return categoryId;
},
@@ -341,9 +319,11 @@ const actions = {
const { categories } = categoryAutomat.state;
if (!categories) return;
const key = String(updated.id);
const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } };
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } },
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
return updated;
},
@@ -357,9 +337,11 @@ const actions = {
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: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } },
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
return res.category;
},
@@ -373,12 +355,14 @@ const actions = {
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: {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
},
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
return res.category;
},
@@ -387,11 +371,11 @@ const actions = {
/** Remove an article from all live category view lists (used after article deletion). */
removeArticleBySku(sku) {
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),
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data?.articles?.some((r) => r.sku === sku)) {
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => r.sku !== sku),
});
}
}
@@ -409,12 +393,14 @@ const actions = {
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: {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
},
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
},
/** Reset state to idle (logout / company switch). */
@@ -432,6 +418,12 @@ const actions = {
*/
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// 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;
companyAutomat.subscribe((company) => {
@@ -463,6 +455,8 @@ export {
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';