Files
dashfront/src/automats/articleIndexAutomat.js
2026-10-04 23:38:46 +02:00

275 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file articleIndexAutomat.js
* @description Company article search index used by the `/artikel` overview,
* the manifest picker and the article-list pickers.
*
* The index is **derived**, not fetched: it is the de-duplicated union of the
* articles of every loaded category view. `categoryAutomat.loadTree()` warms
* those views right after `GET /api/category/tree` resolves, so the index is
* ready without any additional request.
*
* `GET /api/articleIndex` (whole-company window) and the `/article/:sku/articlelist`
* calls are no longer used by this store.
*
* Upstream Triggers:
* - `categoryViewAutomat`: Subscribes to the keyed view store, so a view load,
* reload, article creation or article removal re-derives the index.
* - `categoryAutomat`: Re-derives once `treeStatus === 'succeeded'` and the
* post-tree view preload (`viewsLoading`) settled; `reset()` on company switch.
* - `authAutomat`: Resets to idle on `'anon'`.
* - `sseAutomat`: `actions.reload()` on an `'articles'` event — now only
* re-derives the index from the invalidated category views.
* - `artikelAutomat`: `setArticleIndexName(sku, name)` on optimistic renames and
* `actions.reset()` on mutation failure rollback.
*
* Downstream Triggers:
* - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`.
* - `ArticleSearchDialog`, `ArticleViewLists`: `actions.ensureLoaded()` for a
* flat article list.
*/
import { Automat } from 'react-automat';
import authAutomat from './authAutomat';
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
import {
categoryViews,
collectLoadedCategoryViewData,
loadAllCategoryViews,
} from './categoryViewAutomat';
/**
* @typedef {Object} ArticleIndexRecord
* @property {string|number} id Article id.
* @property {string} sku Article SKU.
* @property {string} name Article name.
* @property {Array<string>} eans Article EANs (not yet served per category — see below).
* @property {string|null} categories Comma-joined category ids.
* @property {boolean} isParent Whether the article has child variants.
*
* @typedef {Object} ArticleIndexState
* @property {Array<ArticleIndexRecord>} items Derived index records.
* @property {'idle'|'loading'|'ready'|'failed'} status Lifecycle state.
* @property {Error|string|null} error Last error.
*/
/** @type {ArticleIndexState} */
const initialState = {
items: [],
status: 'idle',
error: null,
};
let articleIndexAutomat;
/** In-flight `ensureLoaded()` promise so concurrent callers share one rebuild. */
let inflightEnsure = null;
/** Pending coalesced rebuild, scheduled on the microtask queue. */
let rebuildScheduled = false;
/**
* Schedules a rebuild on the next microtask.
*
* Warming every category view fires one notification per view; coalescing keeps
* the derivation at roughly O(articles) instead of O(categories × articles).
*
* @returns {void}
*/
function scheduleRebuild() {
if (rebuildScheduled) return;
rebuildScheduled = true;
queueMicrotask(() => {
rebuildScheduled = false;
actions.rebuild();
});
}
/**
* Normalizes a category-view article row into an index record.
*
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
* field stays empty until that endpoint exposes EANs. EAN matching in
* `articleSearchAutomat` is therefore not functional yet.
*
* @param {object} article Raw category-view article row.
* @param {number|string|null} categoryId Category the article was loaded from.
* @returns {ArticleIndexRecord} Index record.
*/
function toIndexRecord(article, categoryId) {
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
return {
id: article.id,
sku: article.sku,
name: article.name,
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
categories: categoryId != null ? String(categoryId) : null,
isParent: Boolean(article.isParent ?? article.is_parent),
};
}
/**
* Merges the articles of all loaded category views into unique index records.
*
* An article assigned to several categories is emitted once, with every category
* it was seen in accumulated in `categories`.
*
* @param {Array<{categoryId: number|string, articles: Array<object>}>} views Loaded view data.
* @returns {Array<ArticleIndexRecord>} Deduplicated index records.
*/
export function buildArticleIndex(views) {
const bySku = new Map();
for (const view of views) {
const categoryId = view?.categoryId;
for (const article of view?.articles ?? []) {
if (article?.sku == null) continue;
const key = String(article.sku);
const existing = bySku.get(key);
if (!existing) {
bySku.set(key, toIndexRecord(article, categoryId));
continue;
}
if (categoryId != null) {
const ids = new Set(String(existing.categories ?? '').split(',').filter(Boolean));
ids.add(String(categoryId));
existing.categories = Array.from(ids).join(',');
}
existing.isParent = Boolean(existing.isParent || (article.isParent ?? article.is_parent));
}
}
return Array.from(bySku.values());
}
const actions = {
/**
* Rebuilds the index from the currently loaded category views.
*
* Does not hit the network: the data comes from the view cache that
* `categoryAutomat.loadTree()` populated.
*
* @returns {Array<ArticleIndexRecord>} Derived records.
*/
rebuild() {
const items = buildArticleIndex(collectLoadedCategoryViewData());
articleIndexAutomat.setState({ items, status: 'ready', error: null });
return items;
},
/**
* Resolves once the index holds data, rebuilding it when necessary.
*
* Waits for the category tree and for the post-tree view preload, so the
* returned list is complete rather than a partial early snapshot.
*
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
*/
async ensureLoaded() {
if (inflightEnsure) return inflightEnsure;
if (articleIndexAutomat.state.status === 'ready' && articleIndexAutomat.state.items.length > 0) {
return articleIndexAutomat.state.items;
}
articleIndexAutomat.setState({ status: 'loading', error: null });
inflightEnsure = (async () => {
await whenCategoryTreeReady();
// loadTree() already warms every view; this is the idempotent safety net
// for a tree that finished loading before this automat subscribed.
if (categoryAutomat.state.viewsLoading) {
await loadAllCategoryViews();
}
return actions.rebuild();
})()
.catch((error) => {
articleIndexAutomat.setState({ status: 'failed', error });
throw error;
})
.finally(() => {
inflightEnsure = null;
});
return inflightEnsure;
},
/**
* Re-derives the index after the category views were invalidated by SSE.
*
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
*/
async reload() {
return actions.ensureLoaded();
},
/**
* Replaces the cached list without re-deriving it.
*
* @param {Array<ArticleIndexRecord>} items Records to cache.
* @returns {Array<ArticleIndexRecord>} Cached records.
*/
replace(items) {
articleIndexAutomat.setState({ items, status: 'ready', error: null });
return items;
},
/** Reset to idle state (logout / company switch). */
reset() {
inflightEnsure = null;
rebuildScheduled = false;
articleIndexAutomat.setState(initialState);
},
};
/**
* Derived company article search index.
*
* @type {Automat<ArticleIndexState>}
*/
articleIndexAutomat = new Automat(initialState, actions, { name: 'articleIndex' });
export { articleIndexAutomat };
// Re-derive whenever a category view loads, reloads or is patched.
categoryViews.subscribe(() => {
scheduleRebuild();
});
// Re-derive once the tree finished loading and its view preload settled.
categoryAutomat.subscribe((state) => {
if (state.treeStatus === 'succeeded' && state.viewsLoading === false) {
scheduleRebuild();
}
});
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') actions.reset();
});
/**
* Patches a single record's name in the derived index (optimistic update).
*
* @param {string|number} sku Article SKU.
* @param {string} name New article name.
* @returns {void}
*/
export function setArticleIndexName(sku, name) {
const items = articleIndexAutomat.state.items;
if (!items.length) return;
articleIndexAutomat.actions.replace(
items.map((row) =>
String(row.sku) === String(sku) ? { ...row, name } : row,
),
);
}
/**
* Selects the derived index records.
*
* @param {ArticleIndexState} state Automat state.
* @returns {Array<ArticleIndexRecord>} Current records.
*/
export function selectArticleIndexItems(state) {
return state.items;
}
export default articleIndexAutomat;