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';

View File

@@ -32,35 +32,10 @@ export function buildCategoryPath(categories, rootCategoryId, categoryId) {
return categoryPathFromCrumbs(crumbs);
}
export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) {
if (!categories || rootCategoryId == null) return null;
let segments = (slugPath ?? '')
.split('/')
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
// Legacy slug URLs: .../Home/...
if (segments[0] === ROOT_CATEGORY_NAME) {
segments = segments.slice(1);
}
if (segments.length === 0) {
return rootCategoryId;
}
let currentId = rootCategoryId;
for (const slug of segments) {
const children = getCategoryChildren(categories, currentId);
const match = children.find(
(child) => categorySlug(child, rootCategoryId) === slug,
);
if (!match) return null;
currentId = match.id;
}
return currentId;
}
export {
resolveCategoryIdFromSlugPath,
cleanCategorySlugPath,
} from '../../utils/categoryUtils';
export function defaultCategoryPath() {
return KATEGORIEN_BASE;

View File

@@ -25,7 +25,7 @@ import {
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from './categoryRoute';
import categoryAutomat, { getOrCreateCategoryAutomat } from '../../automats/categoryAutomat';
import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats';
@@ -73,18 +73,19 @@ class ArtikelKategorienPage extends PureComponent {
);
}
this.routeAutomat = resolvedCategoryId != null
? getOrCreateCategoryAutomat({ slugPath: props.categorySlugPath, categoryId: resolvedCategoryId })
: null;
this.handle = categoryAutomat.key(props.categorySlugPath ?? '');
const view = this.handle?.state;
const catId = view?.data?.categoryId ?? resolvedCategoryId;
this.state = {
drafts: {},
...catState,
resolvedCategoryId,
articles: this.routeAutomat?.state?.articles ?? [],
loaded: this.routeAutomat?.state?.loaded ?? false,
loading: this.routeAutomat?.state?.loading ?? false,
routeError: this.routeAutomat?.state?.error ?? null,
resolvedCategoryId: catId,
articles: view?.data?.articles ?? [],
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null,
@@ -104,8 +105,7 @@ class ArtikelKategorienPage extends PureComponent {
}));
this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl();
this.subscribeRouteAutomat();
this.subscribeViewHandle();
this.syncSortable();
}
@@ -135,20 +135,27 @@ class ArtikelKategorienPage extends PureComponent {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this._unsubRoute?.();
this._unsubHandle?.();
this.sortableModule?.destroy();
}
subscribeRouteAutomat() {
this._unsubRoute?.();
this._unsubRoute = null;
if (this.routeAutomat) {
this._unsubRoute = this.routeAutomat.subscribe(this, (s) => ({
articles: s.articles,
loaded: s.loaded,
loading: s.loading,
routeError: s.error,
}));
subscribeViewHandle() {
this._unsubHandle?.();
this._unsubHandle = null;
if (this.handle) {
this._unsubHandle = this.handle.subscribe(this, (item) => {
const catId = item.data?.categoryId ?? this.state.resolvedCategoryId;
const categories = this.state.categories;
return {
resolvedCategoryId: catId,
articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: item.loaded,
loading: item.loading,
routeError: item.error,
};
});
void this.handle.load().catch(() => {});
}
}
@@ -217,23 +224,25 @@ class ArtikelKategorienPage extends PureComponent {
navigate(buildCategoryPath(categories, rootCategoryId, resolvedId), { replace: true });
}
const cleanSlug = (categorySlugPath ?? '').replace(/^\/+|\/+$/g, '');
const currentRouteSlug = this.routeAutomat?.state?.slug;
if (resolvedId !== resolvedCategoryId || cleanSlug !== currentRouteSlug || !this.routeAutomat) {
this._unsubRoute?.();
this.routeAutomat = getOrCreateCategoryAutomat({
slugPath: categorySlugPath,
categoryId: resolvedId,
const nextHandle = categoryAutomat.key(categorySlugPath ?? '');
if (this.handle !== nextHandle) {
this.handle = nextHandle;
const view = this.handle.state;
const catId = view?.data?.categoryId ?? resolvedId;
this.setState({
resolvedCategoryId: catId,
articles: view?.data?.articles ?? [],
childCategories: view?.data?.childCategories ?? (catId != null ? getCategoryChildren(categories, catId) : []),
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
});
this.subscribeViewHandle();
} else if (resolvedId !== resolvedCategoryId) {
this.setState({
resolvedCategoryId: resolvedId,
articles: this.routeAutomat?.state?.articles ?? [],
loaded: this.routeAutomat?.state?.loaded ?? false,
loading: this.routeAutomat?.state?.loading ?? false,
routeError: this.routeAutomat?.state?.error ?? null,
childCategories: getCategoryChildren(categories, resolvedId),
});
this.subscribeRouteAutomat();
}
};
@@ -294,14 +303,8 @@ class ArtikelKategorienPage extends PureComponent {
};
handleRetryArticles = () => {
if (this.routeAutomat) {
if (typeof this.routeAutomat.retry === 'function') {
void this.routeAutomat.retry();
} else if (typeof this.routeAutomat.actions?.retry === 'function') {
void this.routeAutomat.actions.retry();
} else {
void this.routeAutomat.setDirty({ keep: false, now: true });
}
if (this.handle) {
void this.handle.reload().catch(() => {});
}
};
@@ -369,7 +372,10 @@ class ArtikelKategorienPage extends PureComponent {
}
const breadcrumbs = getCategoryBreadcrumbs(categories, resolvedCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, resolvedCategoryId);
const childCategories =
this.state.childCategories && this.state.childCategories.length > 0
? this.state.childCategories
: getCategoryChildren(categories, resolvedCategoryId);
const canGoBack =
resolvedCategoryId != null &&
rootCategoryId != null &&

View File

@@ -132,3 +132,48 @@ export function getCategoryDescendantIds(categories, categoryId) {
}
return result;
}
/**
* Normalizes a category slug path by stripping leading/trailing slashes.
* @param {string|null|undefined} slugPath
* @returns {string}
*/
export function cleanCategorySlugPath(slugPath) {
return (slugPath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Resolves a numeric/string category ID from a URL slug path using the category tree.
* @param {object} categories - id -> node map
* @param {number|string|null} rootCategoryId
* @param {string|null|undefined} slugPath
* @returns {number|string|null}
*/
export function resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath) {
if (!categories || rootCategoryId == null) return null;
let segments = (slugPath ?? '')
.split('/')
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
if (segments[0] === ROOT_CATEGORY_NAME) {
segments = segments.slice(1);
}
if (segments.length === 0) {
return rootCategoryId;
}
let currentId = rootCategoryId;
for (const slug of segments) {
const children = getCategoryChildren(categories, currentId);
const match = children.find(
(child) => categorySlug(child, rootCategoryId) === slug,
);
if (!match) return null;
currentId = match.id;
}
return currentId;
}