diff --git a/docs/README.md b/docs/README.md index e1449b5..9826ac5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,8 @@ In `dashfront`, state is managed outside the React component lifecycle using **A | **`artikelAutomat`** | [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js) | [artikelAutomat.md](./artikelAutomat.md) | Cache and reactive distributor for single article detail payloads (`GET /api/article/:sku`) with inflight request deduplication. | | **`authAutomat`** | [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js) | [authAutomat.md](./authAutomat.md) | Manages authentication lifecycle, session bootstrap, Google One Tap login, and multi-tenant company selection. | | **`brandAutomat`** | [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) | [brandAutomat.md](./brandAutomat.md) | Company-scoped catalog brands / manufacturers list (`GET /api/brand`, upsert). Auto-loads on company auth. | -| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), route view article automats, tree ordering, drag-and-drop moves, and image uploads. | +| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), tree ordering, drag-and-drop moves, and image uploads. Triggers the bulk category-view preload once the tree succeeds. | +| **`categoryViewAutomat`** | [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js) | [categoryViewAutomat.md](./categoryViewAutomat.md) | Per-category article views (`KeyedAutomat`) keyed by slug path; all views are warmed right after the tree loads. | | **`companyAutomat`** | [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js) | [companyAutomat.md](./companyAutomat.md) | Active company configuration, tax rates (standard, reduced), root category ID, and company profile info. | | **`deliveryEditorAutomat`** | [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js) | [deliveryEditorAutomat.md](./deliveryEditorAutomat.md) | Multi-line inbound delivery editor, OCR/PDF receipt reconciliation, supplier article auto-matching, and draft badge tracking. | | **`navigationAutomat`** | [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js) | [navigationAutomat.md](./navigationAutomat.md) | Tracks and restores last visited article SKU and delivery paths across top-level section navigation. | diff --git a/docs/categoryAutomat.md b/docs/categoryAutomat.md index 5396631..c56bd75 100644 --- a/docs/categoryAutomat.md +++ b/docs/categoryAutomat.md @@ -1,19 +1,20 @@ # `categoryAutomat` > **Source File**: [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) -> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`) +> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`) +> **Related**: [`categoryViewAutomat`](./categoryViewAutomat.md) — the keyed per-category article views, extracted into their own module. --- ## 1. Overview & Purpose -`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images, POS category visibility flags, and dynamic per-route category article view automats. +`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images and POS category visibility flags. It owns the tree and all category/article **mutations**; the per-category *article views* live in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and are attached to this automat via `attachCategoryTree()`. ### Core Responsibilities - **Hierarchical Category Tree**: Normalized `id → node` dictionary (`categories`) representing the parent-child relational tree. -- **Dynamic Per-Route Category View Automats**: Instantiates lightweight route-scoped automats (`category-view:`) that load category articles on-demand with automatic cache cleanup and disposal. +- **Bulk View Preload**: after `GET /api/category/tree` succeeds, it triggers `loadAllCategoryViews()` so every category's articles are already cached when a route mounts. - **Mutations & Tree Reordering**: Category creation, batch creation, sibling reordering (`sort`), drag-and-drop hierarchy moves, and in-place renaming. -- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the active route automat's article list. +- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the matching category views' article lists. - **Media Management**: Uploading and deleting category cover images and updating AVIF full and thumbnail hashes (`fullHash`, `thumbHash`). - **POS Integration**: Toggling POS visibility and hardware terminal IDs per category. @@ -50,6 +51,8 @@ interface CategoryState { treeStatus: 'idle' | 'loading' | 'succeeded' | 'failed'; /** Last tree error message. */ error: string | null; + /** True while the post-tree preload of all category views is running. */ + viewsLoading: boolean; } ``` @@ -63,29 +66,35 @@ const initialState = { renameMode: false, treeStatus: 'idle', error: null, + viewsLoading: false, }; ``` --- -## 3. Dynamic Category View Automats (`KeyedAutomat`) +## 3. Category View Automats (delegated) -Category views are powered by a `KeyedAutomat` instance (`categoryViews`) indexed by route **slug path** (e.g. `''` for root, `'getraenke'`, `'getraenke/softdrinks'`). +The keyed per-category article views are **no longer defined in this file**. They are implemented in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and re-exported here for backward compatibility: + +| Symbol | Source | +|---|---| +| `categoryViews` | `categoryViewAutomat.js` | +| `getOrCreateCategoryAutomat({ slugPath })` | `categoryViewAutomat.js` | +| `invalidateAllCategoryViews()` | `categoryViewAutomat.js` | +| `disposeAllCategoryViewAutomats()` | `categoryViewAutomat.js` | + +The following facades remain on the tree automat and delegate to `categoryViews`: ### `categoryAutomat.key(slugPath): KeyHandle` Returns a scoped `KeyHandle` proxy for the given route slug. -- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories` (all child categories having the current as their root/parent). +- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories`. - **Handle State**: `{ data: { slug, categoryId, articles, childCategories }, loaded: boolean, loading: boolean, error: string | null }`. - **`handle.load(options?)`**: Deduplicated background fetch across concurrent mounts. - **`handle.read()`**: React Suspense-compatible read (throws in-flight promise if unready). - **`handle.reload()`**: Re-fetches articles and child categories. - **`handle.subscribe(target, selector)`**: Fine-grained subscription to this slug view with zero cross-talk between different category routes. -### `invalidateAllCategoryViews(): void` -Triggers `reload()` across all active slug view handles. - -### `disposeAllCategoryViewAutomats(): void` -Clears all active view handles and frees memory on company switch or logout. +See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`. --- @@ -94,7 +103,8 @@ Clears all active view handles and frees memory on company switch or logout. Access actions via `categoryAutomat.actions.()`. ### Tree Operations -- **`loadTree(): Promise`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, and updates state. +- **`loadTree(): Promise`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, updates state, and then **awaits `loadAllCategoryViews()`** with `viewsLoading: true → false`. Individual category failures are logged as a warning and do not fail the tree load. +- **`loadAllCategoryViews(): Promise<{ loaded: number, failed: Array<{ slug, error }> }>`**: Re-runs the bulk view preload on demand (idempotent — already cached views resolve immediately). - **`setRenameMode(enabled: boolean): void`**: Toggles inline renaming mode for the category explorer. - **`reset(): void`**: Disposes all route automats and resets tree state to `initialState`. diff --git a/docs/categoryViewAutomat.md b/docs/categoryViewAutomat.md new file mode 100644 index 0000000..b24ffdd --- /dev/null +++ b/docs/categoryViewAutomat.md @@ -0,0 +1,136 @@ +# `categoryViewAutomat` + +> **Source File**: [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js) +> **Registration Key**: `'category-views'` (`Automat.get('category-views')` / `window.__AUTOMATS__.get('category-views')`) +> **Related**: [`categoryAutomat`](./categoryAutomat.md) — owns the category **tree** this module resolves against. + +--- + +## 1. Overview & Purpose + +`categoryViewAutomat` owns the **per-category article views**: a `KeyedAutomat` (`categoryViews`) indexed by route **slug path** (`''` for root, `'getraenke'`, `'getraenke/softdrinks'`). Each entry holds one category's articles plus its child categories. + +This module was extracted from [`categoryAutomat.js`](../src/automats/categoryAutomat.js) so the tree and the views can evolve independently. The dependency stays **one-directional**: + +```mermaid +graph LR + CA[categoryAutomat
tree + mutations] -->|attachCategoryTree| CV[categoryViewAutomat
KeyedAutomat views] + CV -->|loadAllCategoryViews| API[/api/category/:id/articles] + CV -->|collectLoadedCategoryViewData| AI[articleIndexAutomat
derived search index] +``` + +`categoryAutomat` injects itself via `attachCategoryTree(automat)`; `categoryViewAutomat` never imports the tree, which avoids a circular import. + +### Core Responsibilities +- **Slug → Category Resolution**: Resolves a slug path against the loaded tree to a `categoryId`. +- **Per-Category Article Payloads**: `GET /api/category/:id/articles` for the resolved category. +- **Bulk Warm-Up**: `loadAllCategoryViews()` loads **every** category in the tree (concurrency-bounded), triggered by `categoryAutomat.loadTree()` the moment the tree succeeds. +- **Child Category Derivation**: Recomputes `childCategories` for all loaded views whenever the tree changes. +- **Incremental Patches**: Appends created articles to, and removes deleted articles from, the affected views. + +--- + +## 2. View Data Structure + +```ts +interface CategoryViewData { + /** Cleaned slug path this view is keyed by. */ + slug: string; + /** Resolved category id. */ + categoryId: number | string; + /** Articles in that category. */ + articles: Array; + /** Direct child category nodes, sorted by `sort`. */ + childCategories: Array; +} +``` + +Each keyed entry wraps this as `ItemState`: +`{ data, loading, loaded, error }`. + +--- + +## 3. Loading Strategy + +### `loadAllCategoryViews({ concurrency }): Promise<{ loaded, failed }>` +Builds the slug path of **every** category node from its breadcrumb trail +(`categoryViewSlugPath()`), de-duplicates them, then loads each with +`categoryViews.load(slug)`. + +- **Already cached views resolve immediately** (`load()` short-circuits when `loaded && !error`), so the call is idempotent. +- Concurrency defaults to **6**; failures are captured per category and returned in `failed` rather than rejecting the batch. +- **Triggered by**: `categoryAutomat.actions.loadTree()` right after `treeStatus === 'succeeded'`, and available on demand via `categoryAutomat.actions.loadAllCategoryViews()`. + +### `reloadLoadedCategoryViews(): Promise` +Re-fetches **only the already cached** views (`setDirty(undefined, false, true)`), keeping the cached set small for SSE-driven refreshes. + +### `invalidateAllCategoryViews(): Promise` +Background-invalidates all loaded views. Called by `sseAutomat` on `'category'` and `'articles'` events. + +### `disposeAllCategoryViewAutomats(): void` +Clears every view and frees memory on company switch or logout. + +### `waitForCategoryTree(): Promise` +Resolves once the attached tree reports `treeStatus === 'succeeded'`. Rejects on tree failure or when `authAutomat` turns `'anon'`, so a pending view never hangs forever. + +--- + +## 4. Exported API + +| Export | Description | +|---|---| +| `categoryViews` (default) | The `KeyedAutomat` keyed by slug path. | +| `attachCategoryTree(automat)` | Injects the tree automat (called once by `categoryAutomat`). | +| `categoryViewSlugPath(categories, rootCategoryId, categoryId)` | Breadcrumb-derived slug path usable as a key. | +| `collectLoadedCategoryViewData()` | All loaded view payloads — the source for the derived article index. | +| `loadAllCategoryViews({ concurrency })` | Bulk warm-up of every category in the tree. | +| `reloadLoadedCategoryViews()` | Re-fetch only the cached views. | +| `invalidateAllCategoryViews()` | Background-invalidate all loaded views. | +| `disposeAllCategoryViewAutomats()` | Clear all views. | +| `getOrCreateCategoryAutomat({ slugPath })` | Returns the `KeyHandle` for a slug path. | +| `refreshCategoryViewChildren(categories)` | Re-derive `childCategories` for every loaded view. | +| `appendArticlesToCategoryViews({ categoryId, articles })` | Append newly created articles. | +| `removeArticleBySku(sku)` | Strip a deleted article from every view. | + +--- + +## 5. Key Handle Usage + +```js +import categoryAutomat from '../automats/categoryAutomat'; + +const handle = categoryAutomat.key('getraenke/softdrinks'); + +handle.subscribe(this, (item) => ({ + articles: item.data?.articles ?? [], + childCategories: item.data?.childCategories ?? [], +})); + +const data = await handle.load(); // deduplicated across concurrent mounts +const data2 = handle.read(); // Suspense: throws the in-flight promise when unready +handle.reload(); +handle.setDirty(); +``` + +All four facades (`key`, `read`, `load`, `views`) are attached to `categoryAutomat` +as thin delegates to `categoryViews`, so existing import sites keep working. + +--- + +## 6. Cross-Automat Coordination + +| Automat | Interaction | +|---|---| +| `categoryAutomat` | `attachCategoryTree()`, `loadAllCategoryViews()` after the tree succeeds, `refreshCategoryViewChildren()` on every tree mutation, `disposeAllCategoryViewAutomats()` on reset. | +| `authAutomat` | `waitForCategoryTree()` rejects pending views on `'anon'`. | +| `sseAutomat` | `invalidateAllCategoryViews()` on `'category'` / `'articles'` events. | +| `articleIndexAutomat` | Subscribes to `categoryViews` and re-derives the search index from `collectLoadedCategoryViewData()`. | + +--- + +## 7. Gotchas + +- **Bulk warm-up cost**: `loadTree()` now issues one request per category. On very large trees this is many requests — `concurrency` (default 6) is the throttle to tune. Individual failures are non-fatal. +- **`viewsLoading`**: While it is `true`, the tree is ready but the views are still warming. Use it to decide between "render a spinner" and "render an empty grid". +- **Keys are slug paths, not ids**: A rename or move changes the slug, so the view key changes and the old entry should be evicted; `refreshCategoryViewChildren()` handles the child lists, but stale keys may linger until the next `disposeAllCategoryViewAutomats()`. +- **Do not import `categoryAutomat` from this file** — that would create the circular dependency the `attachCategoryTree()` injection exists to avoid. \ No newline at end of file diff --git a/src/api/index.js b/src/api/index.js index ea7af74..82b9e0b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,6 +1,6 @@ // Single source of truth for all backend communication. -import { requireArray, requireObject } from '../utils/apiShape'; + /** WebView consoles stringify objects as [object Object]; log JSON text. */ function logApi(tag, details) { @@ -190,37 +190,14 @@ async function httpGetBinary(path) { } /** - * Company-scoped article search index (selectedCompanyId from session). - * GET /api/articleIndex → { articles: [...] } + * NOTE: there is no `articleIndex` namespace anymore. + * + * The whole-company search index (`GET /api/articleIndex`) was abandoned. Article + * lists are fetched per category via `category.getArticles(categoryId)` + * (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every + * category view right after the tree loads, and `articleIndexAutomat` derives the + * client-side search index from those views. */ -export const articleIndex = { - list() { - return http('/api/articleIndex'); - }, - - /** - * Fetches the company article index and maps backend rows to the recordset - * shape consumed by the search result list. - * - * `categories` becomes a comma-joined string because `formatCategoryList` - * splits on ','. - * - * @returns {Promise>} - */ - async listRecords() { - const data = await http('/api/articleIndex'); - requireObject(data, 'GET /api/articleIndex'); - const articles = requireArray(data.articles, 'articleIndex.articles'); - return articles.map((a) => ({ - id: a.id, - sku: a.sku, - name: a.name, - eans: a.eans ?? [], - categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null, - isParent: Boolean(a.isParent ?? a.is_parent), - })); - }, -}; export const article = { search({ target, filter, offset, maxCount } = {}) { @@ -1970,7 +1947,6 @@ export const modelconversation = { export default { auth, article, - articleIndex, attributename, attributevalue, brand, diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js index c3937ad..7523227 100644 --- a/src/automats/articleIndexAutomat.js +++ b/src/automats/articleIndexAutomat.js @@ -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} eans Article EANs. + * @property {Array} 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} 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} + * @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}>} views Loaded view data. + * @returns {Array} 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} 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>} 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>} Derived records. + */ + async reload() { + return actions.ensureLoaded(); + }, + + /** + * Replaces the cached list without re-deriving it. + * + * @param {Array} items Records to cache. + * @returns {Array} 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} + */ +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} Current records. + */ +export function selectArticleIndexItems(state) { + return state.items; +} + export default articleIndexAutomat; diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index 0040a38..c9d02e7 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -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} Search outcome. diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js index be96222..48e56d5 100644 --- a/src/automats/authAutomat.js +++ b/src/automats/authAutomat.js @@ -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 */ diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 63b2174..6de02e0 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -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; diff --git a/src/automats/createListAutomat.js b/src/automats/createListAutomat.js index 31bdb1d..62918d2 100644 --- a/src/automats/createListAutomat.js +++ b/src/automats/createListAutomat.js @@ -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'; diff --git a/src/automats/index.js b/src/automats/index.js index f39b26c..abad189 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -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. diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index d2c6a6a..32903cd 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -14,7 +14,8 @@ * Reloads SKU-keyed views on `article-` 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.