u
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user