This commit is contained in:
seb
2026-10-04 23:38:46 +02:00
parent a90069d340
commit 81b59fd309
11 changed files with 454 additions and 83 deletions

View File

@@ -1,48 +1,251 @@
/**
* @file articleIndexAutomat.js
* @description Cached company article search index (`GET /api/articleIndex`).
* Provides fast client-side searching, manifest pickers, and optimistic updates.
* Built via `createListAutomat`.
* @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:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - `sseAutomat`: Triggers `reload()` upon receiving an `'articles'` SSE event.
* - `artikelAutomat`: Triggers `setArticleIndexName(sku, name)` on optimistic
* article renames, or `reset()` on mutation failure rollback.
* - `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 { articleIndex as articleIndexApi } from '../api';
import { createListAutomat } from './createListAutomat';
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.
* @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;
/**
* Company article search index (`GET /api/articleIndex`).
* Schedules a rebuild on the next microtask.
*
* Backs the article search page, the manifest article picker, and the
* optimistic name patches applied by `artikelAutomat`.
* Warming every category view fires one notification per view; coalescing keeps
* the derivation at roughly O(articles) instead of O(categories × articles).
*
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
* @returns {void}
*/
export const articleIndexAutomat = createListAutomat({
name: 'articleIndex',
fetch: () => articleIndexApi.listRecords(),
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 cached index (optimistic update).
* Patches a single record's name in the derived index (optimistic update).
*
* @param {string|number} sku Article SKU.
* @param {string} name New article name.
@@ -58,4 +261,14 @@ export function setArticleIndexName(sku, name) {
);
}
/**
* 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;