u
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @file articleSearchAutomat.js
|
||||
* @description In-memory article search state for the `/artikel` overview and
|
||||
* search pages, querying against the cached `articleIndexAutomat`.
|
||||
* search pages, querying against the derived `articleIndexAutomat`.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`.
|
||||
* - Reads data from `articleIndexAutomat`: Ensures the cached index is loaded
|
||||
* before filtering.
|
||||
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
|
||||
* before filtering. The index is built from the loaded category views, so a
|
||||
* search never issues a request of its own.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
@@ -61,8 +62,8 @@ const actions = {
|
||||
/**
|
||||
* Runs a client-side search over the article index.
|
||||
*
|
||||
* The full index is already cached by `articleIndexAutomat`, so this does not
|
||||
* hit the network once the index has loaded.
|
||||
* The index is already derived from the cached category views, so this does
|
||||
* not hit the network once the tree has loaded.
|
||||
*
|
||||
* @param {string} target Search term; an empty string returns everything.
|
||||
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat`
|
||||
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
|
||||
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
|
||||
* - `createListAutomat` instances (`articleIndexAutomat`, `deliveryAutomat`,
|
||||
* `quoteAutomat`, `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
|
||||
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
|
||||
* the category views once the session is established
|
||||
* - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`,
|
||||
* `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
|
||||
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
|
||||
* - `artikelAutomat`: Clears cached articles on logout
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
* - `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.
|
||||
* - `articleIndexAutomat`: Derives its client-side search index from the union of
|
||||
* the loaded category views, so the index costs no requests of its own and
|
||||
* `GET /api/articleIndex` is no longer used.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
@@ -458,8 +461,10 @@ export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../util
|
||||
export {
|
||||
categoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
disposeAllCategoryViewAutomats,
|
||||
loadAllCategoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
export default categoryAutomat;
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
*
|
||||
* Used by:
|
||||
* - `articleIndexAutomat`, `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
||||
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
||||
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
|
||||
*
|
||||
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
|
||||
* category views rather than fetched from a whole-company endpoint.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
|
||||
@@ -29,11 +29,19 @@ export {
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
|
||||
/** Article search over the cached index (replaces StorageManager.doSearch). */
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat';
|
||||
|
||||
/** Company-scoped list stores (replaced the old StorageManager caches). */
|
||||
export { default as articleIndexAutomat, setArticleIndexName } from './articleIndexAutomat';
|
||||
/**
|
||||
* Derived article search index: the union of every loaded category view.
|
||||
* Replaces the former `GET /api/articleIndex` list store.
|
||||
*/
|
||||
export {
|
||||
default as articleIndexAutomat,
|
||||
setArticleIndexName,
|
||||
buildArticleIndex,
|
||||
selectArticleIndexItems,
|
||||
} from './articleIndexAutomat';
|
||||
export { default as deliveryAutomat } from './deliveryAutomat';
|
||||
export { default as quoteAutomat } from './quoteAutomat';
|
||||
export { default as orderAutomat } from './orderAutomat';
|
||||
@@ -90,14 +98,28 @@ export {
|
||||
categoryDisplayName,
|
||||
categorySlug,
|
||||
normalizeCategoryMap,
|
||||
categoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
getCategoryState,
|
||||
whenCategoryTreeReady,
|
||||
} from './categoryAutomat';
|
||||
|
||||
/**
|
||||
* Per-category article views keyed by slug path, warmed in bulk by
|
||||
* `categoryAutomat.loadTree()` once the tree succeeded.
|
||||
*/
|
||||
export {
|
||||
default as categoryViews,
|
||||
attachCategoryTree,
|
||||
categoryViewSlugPath,
|
||||
collectLoadedCategoryViewData,
|
||||
loadAllCategoryViews,
|
||||
reloadLoadedCategoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
refreshCategoryViewChildren,
|
||||
appendArticlesToCategoryViews,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
||||
|
||||
@@ -167,7 +189,6 @@ export {
|
||||
* ----------------------------------------------------------------------------
|
||||
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
|
||||
* and automatic synchronization with `authAutomat`:
|
||||
* - `articleIndexAutomat`: Fast client-side search index (`GET /api/articleIndex`).
|
||||
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
|
||||
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
|
||||
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
|
||||
@@ -177,6 +198,8 @@ export {
|
||||
*
|
||||
* 6. Search & Infinite-Scroll Grid Automats
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
|
||||
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
|
||||
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
|
||||
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
|
||||
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* Reloads SKU-keyed views on `article-<sku>` signals.
|
||||
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()`
|
||||
* on `'category'` SSE events.
|
||||
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events.
|
||||
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events;
|
||||
* the index is derived from the reloaded category views, so no extra request.
|
||||
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events.
|
||||
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
|
||||
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
|
||||
|
||||
Reference in New Issue
Block a user