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