Files
dashfront/docs/categoryAutomat.md
2026-10-04 23:38:46 +02:00

289 lines
12 KiB
Markdown

# `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')`)
> **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 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.
- **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 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.
---
## 2. State Structure
```ts
interface CategoryNode {
id: number | string;
name: string;
parentId: number | string | null;
sort: number;
pos?: boolean;
fullHash?: string | null;
thumbHash?: string | null;
thumbW?: number | null;
thumbH?: number | null;
posterminalIds?: Array<number>;
childCount?: number;
[key: string]: any;
}
interface CategoryState {
companyId: number | string | null;
languageId: string | null;
/** Normalized category map keyed by string ID. */
categories: Record<string, CategoryNode> | null;
/** Root (home) category ID for the current company. */
rootCategoryId: number | string | null;
/** Whether category tiles are in rename/edit mode. */
renameMode: boolean;
/** Category tree loading status. */
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;
}
```
### Initial State
```js
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
```
---
## 3. Category View Automats (delegated)
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`.
- **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.
See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`.
---
## 4. Actions & Methods
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, 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`.
### Category Mutations
- **`createCategory({ parentId, name }): Promise<CategoryNode>`**: Creates a category via `POST /api/category` and inserts it into `categories`.
- **`createCategories({ parentId, names }): Promise<CategoryNode[]>`**: Batch-creates multiple categories under `parentId`.
- **`renameCategory({ categoryId, name }): Promise<CategoryNode>`**: Updates a category name via `PATCH /api/category/:id/name`. Protects root category from being renamed.
- **`reorderCategories({ parentId, categoryIds }): Promise<number[]>`**: Updates sibling display ordering via `POST /api/category/:parentId/reorder`.
- **`moveCategory({ categoryId, parentId }): Promise<object>`**: Moves a category under a new parent via `PATCH /api/category/:id/move`.
- **`deleteCategory(categoryId): Promise<number|string>`**: Deletes a category via `DELETE /api/category/:id` and removes it from state.
- **`toggleCategoryPos({ categoryId, pos }): Promise<CategoryNode>`**: Toggles POS visibility via `PATCH /api/category/:id/pos`.
### Article Mutations within Categories
- **`createArticle({ parentId, name }): Promise<object>`**: Dispatches `POST /api/article` and appends the new article to the active category view automat's `articles` list immediately.
- **`createArticles({ parentId, names }): Promise<object[]>`**: Batch creates articles and updates the active route view.
- **`removeArticleBySku(sku: string): void`**: Removes an article from all live category view article lists when deleted.
### Category Media
- **`uploadCategoryImage({ categoryId, file }): Promise<object>`**: Uploads an image blob to `POST /api/category/:id/image` and patches `fullHash`, `thumbHash`, `thumbW`, and `thumbH` in the category node.
- **`deleteCategoryImage(categoryId): Promise<object>`**: Deletes the category image via `DELETE /api/category/:id/image`.
### Hardware POS Terminals
- **`patchCategoryPosterminalIds({ categoryId, terminalId, enabled }): void`**: Updates the `posterminalIds` array on a category node locally.
---
## 5. Exported Tree Utilities
Pure helper functions re-exported from `../utils/categoryUtils`:
- **`ROOT_CATEGORY_NAME`**: Constant for the root category.
- **`getCategoryBreadcrumbs(categories, categoryId): Array<CategoryNode>`**: Traverses parent pointers from node to root.
- **`getCategoryChildren(categories, parentId): Array<CategoryNode>`**: Returns all immediate child nodes sorted by `sort`.
- **`categoryDisplayName(category): string`**: Formats category display label.
- **`categorySlug(name): string`**: Generates a clean URL slug.
- **`normalizeCategoryNode(raw): CategoryNode`**: Normalizes API category payload.
---
## 6. Cross-Automat Coordination
`categoryAutomat` automatically stays in sync with `companyAutomat` and `authAutomat`:
```js
// Sync with companyAutomat:
companyAutomat.subscribe((company) => {
const id = company.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
disposeAllCategoryViewAutomats();
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Reset on logout:
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
```
---
## 7. Usage Examples
### A. React `PureComponent` Category Tree Browser
```jsx
import { PureComponent } from 'react';
import categoryAutomat, { getCategoryChildren, getCategoryBreadcrumbs } from '../automats/categoryAutomat';
export class CategoryBrowser extends PureComponent {
constructor(props) {
super(props);
this.state = {
categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId,
treeStatus: categoryAutomat.state.treeStatus,
};
}
componentDidMount() {
this.unsub = categoryAutomat.subscribe(this, ['categories', 'rootCategoryId', 'treeStatus']);
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { categories, rootCategoryId, treeStatus } = this.state;
if (treeStatus === 'loading' || !categories) return <div>Lade Kategorien...</div>;
const currentParentId = this.props.categoryId ?? rootCategoryId;
const subcategories = getCategoryChildren(categories, currentParentId);
const breadcrumbs = getCategoryBreadcrumbs(categories, currentParentId);
return (
<div>
<nav>
{breadcrumbs.map((b) => (
<span key={b.id}>{b.name} / </span>
))}
</nav>
<ul>
{subcategories.map((cat) => (
<li key={cat.id}>{cat.name}</li>
))}
</ul>
</div>
);
}
}
```
### B. Consuming a Per-Route Category View with `categoryAutomat.key()`
```jsx
import { PureComponent } from 'react';
import categoryAutomat from '../automats/categoryAutomat';
export class CategoryArticlesList extends PureComponent {
constructor(props) {
super(props);
this.handle = categoryAutomat.key(props.slugPath);
this.state = {
articles: this.handle.state.data?.articles ?? [],
childCategories: this.handle.state.data?.childCategories ?? [],
loading: this.handle.state.loading,
};
}
componentDidMount() {
this.unsub = this.handle.subscribe(this, (item) => ({
articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? [],
loading: item.loading,
}));
this.handle.load();
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { articles, childCategories, loading } = this.state;
if (loading) return <div>Lade Artikel in dieser Kategorie...</div>;
return (
<div>
<h3>Unterkategorien ({childCategories.length})</h3>
<ul>
{childCategories.map((cat) => (
<li key={cat.id}>{cat.name}</li>
))}
</ul>
<h3>Artikel ({articles.length})</h3>
<ul>
{articles.map((art) => (
<li key={art.sku}>{art.name} ({art.sku})</li>
))}
</ul>
</div>
);
}
}
```
---
## 8. Best Practices & Gotchas
1. **Root category cannot be renamed or deleted**: The backend returns HTTP 400 if you attempt to rename or delete the company root category.
2. **Reverse index updates**: When creating articles with `categoryAutomat.actions.createArticle`, any active route automat matching the parent ID is updated immediately.
3. **Use normalized helpers**: Always use `getCategoryChildren(categories, parentId)` rather than manual filtering; it ensures correct sort order.