This commit is contained in:
seb
2026-10-04 22:47:26 +02:00
parent 8b401fa50b
commit a90069d340
44 changed files with 994 additions and 268 deletions

View File

@@ -1,14 +1,46 @@
import { Automat, KeyedAutomat } from 'react-automat';
/**
* @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.
*/
import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import {
attachCategoryTree,
categoryViews,
loadAllCategoryViews,
disposeAllCategoryViewAutomats,
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
removeArticleBySku,
waitForCategoryTree,
} from './categoryViewAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils';
/**
@@ -20,6 +52,7 @@ import {
* @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} */
@@ -31,120 +64,12 @@ const initialState = {
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
let categoryAutomat;
let _treeLoadingPromise = null;
function waitForCategoryTree() {
if (categoryAutomat.state.treeStatus === 'succeeded') {
return Promise.resolve(categoryAutomat.state);
}
return new Promise((resolve, reject) => {
let unsubCat = null;
let unsubAuth = null;
const cleanup = () => {
unsubCat?.();
unsubAuth?.();
};
unsubCat = categoryAutomat.subscribe((state) => {
if (state.treeStatus === 'succeeded') {
cleanup();
resolve(state);
} else if (state.treeStatus === 'failed') {
cleanup();
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
}
});
unsubAuth = authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
cleanup();
reject(new Error('Nicht authentifiziert.'));
}
});
});
}
/**
* 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') {
if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree();
}
await waitForCategoryTree();
}
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,
};
},
}
);
/**
* Updates childCategories on all loaded category view handles when categories tree changes.
*/
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.
* (Backward compatibility helper)
*/
export function getOrCreateCategoryAutomat({ slugPath }) {
const slug = cleanCategorySlugPath(slugPath);
return categoryViews.key(slug);
}
/**
* Invalidate all active category view automats by triggering background reload.
*/
export function invalidateAllCategoryViews() {
return categoryViews.setDirty(undefined, false, true);
}
/**
* Dispose all category view automats and clear registries (e.g. on logout or company switch).
*/
export function disposeAllCategoryViewAutomats() {
categoryViews.clear();
}
/**
* Returns the parsed category map and root id, or `null` while the tree is
* still loading. Replaces the former `StorageManager.getCategoryState()`.
@@ -194,11 +119,25 @@ const actions = {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
viewsLoading: true,
});
refreshLoadedCategoryViewChildren(parsed.categories);
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;
@@ -215,6 +154,15 @@ const actions = {
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 ────────────────────────────────────────────────────
/**
@@ -227,7 +175,7 @@ const actions = {
const { categories } = categoryAutomat.state;
const updated = { ...(categories ?? {}), [String(node.id)]: node };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return node;
},
@@ -246,7 +194,7 @@ const actions = {
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return created;
},
@@ -257,16 +205,7 @@ const actions = {
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
if (created && parentId != null) {
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],
});
}
}
}
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
},
@@ -282,16 +221,7 @@ const actions = {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
if (created.length > 0 && parentId != null) {
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],
});
}
}
}
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
return created;
},
@@ -313,7 +243,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -334,7 +264,7 @@ const actions = {
}
}
categoryAutomat.setState({ categories: patched });
refreshLoadedCategoryViewChildren(patched);
refreshCategoryViewChildren(patched);
return ordered;
},
@@ -355,7 +285,7 @@ const actions = {
categoryAutomat.setState({
categories: updated,
});
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return moved;
},
@@ -370,7 +300,7 @@ const actions = {
const next = { ...categories };
delete next[String(categoryId)];
categoryAutomat.setState({ categories: next });
refreshLoadedCategoryViewChildren(next);
refreshCategoryViewChildren(next);
return categoryId;
},
@@ -388,7 +318,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -406,7 +336,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return res.category;
},
@@ -427,7 +357,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return res.category;
},
@@ -435,15 +365,7 @@ const actions = {
/** Remove an article from all live category view lists (used after article deletion). */
removeArticleBySku(sku) {
if (!sku) return;
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),
});
}
}
removeArticleBySku(sku);
},
/**
@@ -465,12 +387,13 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
_treeLoadingPromise = null;
categoryAutomat.setState(initialState);
},
};
@@ -483,6 +406,9 @@ const actions = {
*/
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));
@@ -528,4 +454,12 @@ export {
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
// Re-export category view automats and actions
export {
categoryViews,
getOrCreateCategoryAutomat,
disposeAllCategoryViewAutomats,
invalidateAllCategoryViews,
} from './categoryViewAutomat';
export default categoryAutomat;