/** * @file categoryViewAutomat.js * @description Owns the per-category view automats (`categoryViews`), a * `KeyedAutomat` keyed by category slug path. Each entry holds one category's * articles (`GET /api/category/:id/articles`) plus its child categories, and is * resolved from the category slug path against the loaded category tree. * * This file holds ONLY the keyed per-category views. The category tree itself * (`categories` map, `rootCategoryId`, category/article mutations) lives in * `categoryAutomat.js`; this module receives it through `attachCategoryTree()` * so the dependency stays one-directional (views → tree). * * Upstream Triggers: * - `categoryAutomat`: The tree automat attaches itself via `attachCategoryTree()` * and calls `loadAllCategoryViews()` once `treeStatus === 'succeeded'`, * `reloadLoadedCategoryViews()` when the tree changes, and * `disposeAllCategoryViewAutomats()` on `reset()` (logout / company switch). * - `sseAutomat`: Calls `invalidateAllCategoryViews()` on a `'category'` event and * on an `'articles'` event for the open views. * - UI / Routes: `categoryAutomat.key(slugPath)` / * `categoryAutomat.read(slugPath)` / `categoryAutomat.load(slugPath)` facades. * * Downstream Triggers: * - Routes / components: `src/routes/artikel/kategorien.jsx` (grid + drag & drop), * `src/components/category/CategoryTree.jsx`. */ import { KeyedAutomat } from 'react-automat'; import { category as categoryApi } from '../api'; import authAutomat from './authAutomat'; import { getCategoryBreadcrumbs, getCategoryChildren, cleanCategorySlugPath, resolveCategoryIdFromSlugPath, } from '../utils/categoryUtils'; /** * @typedef {Object} CategoryViewData * @property {string} slug Cleaned slug path this view was keyed by. * @property {number|string} categoryId Resolved category id. * @property {Array} articles Articles in that category. * @property {Array} childCategories Direct child category nodes. */ /** * The tree automat, injected by `categoryAutomat.js` at module-evaluation time. * @type {import('react-automat').Automat|null} */ let treeAutomat = null; /** * Injects the category tree automat this module resolves slug paths against. * * @param {import('react-automat').Automat} automat Tree automat. * @returns {void} */ export function attachCategoryTree(automat) { treeAutomat = automat; } /** * Returns the attached tree automat. * * @returns {import('react-automat').Automat} Tree automat. * @throws {Error} When the tree automat has not been attached yet. */ function tree() { if (!treeAutomat) { throw new Error('categoryAutomat has not attached the category tree yet.'); } return treeAutomat; } /** * Resolves once the attached category tree has loaded successfully. * * Rejects when the tree load fails or the session becomes anonymous, so callers * waiting on a view never hang forever. * * @returns {Promise} Tree state with `treeStatus === 'succeeded'`. */ export function waitForCategoryTree() { const current = tree().state; if (current.treeStatus === 'succeeded') return Promise.resolve(current); return new Promise((resolve, reject) => { let unsubTree = null; let unsubAuth = null; const cleanup = () => { unsubTree?.(); unsubAuth?.(); unsubTree = null; unsubAuth = null; }; unsubTree = tree().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.')); } }); }); } /** * Per-route category view stores, keyed by cleaned category slug path. * * @type {KeyedAutomat} */ export const categoryViews = new KeyedAutomat( null, {}, { name: 'category-views', loader: async (slugPath) => { const slug = cleanCategorySlugPath(slugPath); // The tree must be loaded before a slug path can be resolved to an id. const treeState = tree().state; if (treeState.treeStatus !== 'succeeded') { if (treeState.treeStatus === 'idle') { void tree().actions.loadTree(); } await waitForCategoryTree(); } const { categories, rootCategoryId } = tree().state; const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug); if (categoryId == null) { throw new Error(`Kategorie für Pfad „${slug}“ 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 }; }, }, ); /** * Runs `worker` over `items` with a bounded number of concurrent calls. * Failures are captured per item instead of rejecting the whole batch. * * @template T, R * @param {Array} items Work items. * @param {number} limit Maximum concurrent workers. * @param {(item: T, index: number) => Promise} worker Async work per item. * @returns {Promise>} Ordered results. */ async function mapWithConcurrency(items, limit, worker) { const results = new Array(items.length); let cursor = 0; const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { while (cursor < items.length) { const index = cursor++; try { results[index] = { ok: true, value: await worker(items[index], index) }; } catch (error) { results[index] = { ok: false, error }; } } }); await Promise.all(runners); return results; } /** * Builds the slug path for a category from its breadcrumb trail. * * @param {Record} categories id → node map. * @param {number|string} rootCategoryId Root category id. * @param {number|string} categoryId Category id. * @returns {string} Slug path usable as a `categoryViews` key. */ export function categoryViewSlugPath(categories, rootCategoryId, categoryId) { return getCategoryBreadcrumbs(categories, categoryId, rootCategoryId) .map((crumb) => crumb.slug) .filter(Boolean) .join('/'); } /** * Collects the data of every currently loaded category view. * * Used by `articleIndexAutomat` to derive the search index from the view cache * without issuing additional requests. * * @returns {Array} Loaded view payloads. */ export function collectLoadedCategoryViewData() { const out = []; for (const item of Object.values(categoryViews.state)) { if (item?.loaded && item.data) out.push(item.data); } return out; } /** * Loads a view for **every** category in the loaded tree. * * Called by `categoryAutomat` once the tree finished loading, so navigating to * any category (and the derived article index) resolves from cache instead of * waiting for a round-trip. Already-loaded views are skipped. * * @param {{ concurrency?: number }} [options] Load tuning. * @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary. */ export async function loadAllCategoryViews({ concurrency = 6 } = {}) { const treeState = tree().state; if (treeState.treeStatus !== 'succeeded') { return { loaded: 0, failed: [] }; } const { categories, rootCategoryId } = treeState; const slugs = []; const seen = new Set(); for (const node of Object.values(categories ?? {})) { const slug = categoryViewSlugPath(categories, rootCategoryId, node.id); if (!slug || seen.has(slug)) continue; seen.add(slug); slugs.push(slug); } const results = await mapWithConcurrency(slugs, concurrency, (slug) => // Force only when not already cached; `load()` resolves immediately otherwise. categoryViews.load(slug), ); const failed = []; let loaded = 0; results.forEach((result, index) => { if (result.ok) loaded += 1; else failed.push({ slug: slugs[index], error: result.error }); }); return { loaded, failed }; } /** * Re-fetches only the views that are already cached, keeping the set of cached * categories small. Used for SSE-driven refreshes. * * @returns {Promise} Resolves once all cached views settled. */ export async function reloadLoadedCategoryViews() { await categoryViews.setDirty(undefined, false, true); } /** * Background-invalidates all loaded category views. * * @returns {Promise} Per-view reload promises. */ export function invalidateAllCategoryViews() { return categoryViews.setDirty(undefined, false, true); } /** * Disposes every category view (logout / company switch). * * @returns {void} */ export function disposeAllCategoryViewAutomats() { categoryViews.clear(); } /** * Get or create the view handle for a slug path (backward compatible helper). * * @param {{ slugPath: string }} param0 * @returns {import('react-automat').KeyHandle} Keyed view handle. */ export function getOrCreateCategoryAutomat({ slugPath }) { return categoryViews.key(cleanCategorySlugPath(slugPath)); } /** * Re-derives `childCategories` for every loaded view after the tree changed. * * @param {Record|null} categories Fresh id → node map. * @returns {void} */ export function refreshCategoryViewChildren(categories) { if (!categories) return; for (const [slug, item] of categoryViews._items.entries()) { if (item.data && item.data.categoryId != null) { categoryViews.key(slug).set({ ...item.data, childCategories: getCategoryChildren(categories, item.data.categoryId), }); } } } /** * Appends newly created articles to every loaded view of `categoryId`. * * @param {{ categoryId: number|string, articles: Array }} param0 * @returns {void} */ export function appendArticlesToCategoryViews({ categoryId, articles }) { if (!articles?.length || categoryId == null) return; for (const [slug, item] of categoryViews._items.entries()) { if (item.data && String(item.data.categoryId) === String(categoryId)) { categoryViews.key(slug).set({ ...item.data, articles: [...(item.data.articles || []), ...articles], }); } } } /** * Removes a deleted article from every loaded view that contains it. * * @param {string} sku Article SKU. * @returns {void} */ export function 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), }); } } } export default categoryViews;