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

@@ -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. |

View File

@@ -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:<slug>`) 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.<method>()`.
### Tree Operations
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, and updates state.
- **`loadTree(): Promise<void>`**: 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`.

136
docs/categoryViewAutomat.md Normal file
View File

@@ -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<br/>tree + mutations] -->|attachCategoryTree| CV[categoryViewAutomat<br/>KeyedAutomat views]
CV -->|loadAllCategoryViews| API[/api/category/:id/articles]
CV -->|collectLoadedCategoryViewData| AI[articleIndexAutomat<br/>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<ArticleRow>;
/** Direct child category nodes, sorted by `sort`. */
childCategories: Array<CategoryNode>;
}
```
Each keyed entry wraps this as `ItemState<CategoryViewData>`:
`{ 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<void>`
Re-fetches **only the already cached** views (`setDirty(undefined, false, true)`), keeping the cached set small for SSE-driven refreshes.
### `invalidateAllCategoryViews(): Promise<unknown[]>`
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<CategoryState>`
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.

View File

@@ -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<Array<ArticleIndexRecord>>}
*/
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,

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;

View File

@@ -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.

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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';

View File

@@ -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.

View File

@@ -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.