u
This commit is contained in:
276
.agents/skills/coding-style/SKILL.md
Normal file
276
.agents/skills/coding-style/SKILL.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
name: coding-style
|
||||||
|
description: >-
|
||||||
|
Coding and project style conventions for the Dashfront repository.
|
||||||
|
Use this skill whenever authoring, modifying, or reviewing code, creating or updating
|
||||||
|
automats, writing React PureComponents, integrating dashgui components, or structuring
|
||||||
|
backend API and SSE communication. Names src/automats/brandAutomat.js as the canonical reference automat.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dashfront Coding & Project Style Guide
|
||||||
|
|
||||||
|
This skill defines the canonical coding standards, architectural patterns, and project conventions for the `dashfront` codebase.
|
||||||
|
|
||||||
|
> **Canonical Reference Automat**: [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)
|
||||||
|
> **Canonical Reference View**: [src/routes/artikel/marken.jsx](file:///home/seb/src/dashfront/src/routes/artikel/marken.jsx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Architectural Principles
|
||||||
|
|
||||||
|
1. **State Lives Outside React**: Domain and configuration state lives in module-scoped `react-automat` stores (`src/automats/*`), independent of component lifecycles.
|
||||||
|
2. **Zero Blank Flash**: Components read initial state **synchronously** in the `constructor` from the automat before subscribing in `componentDidMount`. Never show empty flicker when data is already cached.
|
||||||
|
3. **PureComponents Over Hooks for Stateful Views**: Views and widgets subscribe directly via `automat.subscribe(this, selector)` in React `PureComponent` classes. Do **not** use React hooks (`useState`, `useEffect`) inside `PureComponent` classes.
|
||||||
|
4. **No React Context for Data**: Never create React Context providers for domain models. Components import the automat or `src/api` namespace directly.
|
||||||
|
5. **No Mock Backends**: Never mock API routes or fake endpoints. The real Express backend runs locally on port `4208` (`dashback`), proxied via Vite.
|
||||||
|
6. **Silent SSE Updates**: Real-time server-sent events trigger **silent reloads** (`actions.reload()`), preserving visible UI state without resetting to `'loading'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Canonical Reference Automat: `brandAutomat.js`
|
||||||
|
|
||||||
|
All configuration and list automats in `src/automats/` must follow the structure and conventions exemplified by [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/automats/brandAutomat.js
|
||||||
|
├── 1. File JSDoc with Upstream Triggers documentation
|
||||||
|
├── 2. Imports (react-automat, api, authAutomat, sseAutomat)
|
||||||
|
├── 3. JSDoc State Typedef & initialState
|
||||||
|
├── 4. Pure Helper Functions (sorting, filtering)
|
||||||
|
├── 5. Concurrency control (inflightLoad guard, pagination)
|
||||||
|
├── 6. State Updater Helper (updateStateWithBrands)
|
||||||
|
├── 7. Actions Object (filter, load, reload, CRUD, reset)
|
||||||
|
├── 8. Automat Singleton Instantiation (new Automat(initialState, actions, { name }))
|
||||||
|
├── 9. Upstream Subscriptions (authAutomat lifecycle, sseAutomat events)
|
||||||
|
└── 10. Default Export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Patterns Illustrated by `brandAutomat.js`
|
||||||
|
|
||||||
|
#### A. Upstream Triggers Documentation
|
||||||
|
Always document the trigger contract at the top of the file:
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @file brandAutomat.js
|
||||||
|
* @description Manages catalog brands and manufacturers for the active company.
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||||
|
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||||
|
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
|
||||||
|
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
|
||||||
|
* `actions.reload()`, which silently re-fetches the list. Events sent by this tab
|
||||||
|
* (matching `sender`) are ignored.
|
||||||
|
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Predictable State Shape & Status Progression
|
||||||
|
Define state via JSDoc `@typedef` with standardized status progression:
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BrandState
|
||||||
|
* @property {Array<object>} brands All items (full list, decoupled from page UI).
|
||||||
|
* @property {Array<object>} filteredBrands Filtered items matching active filter.
|
||||||
|
* @property {string} filter Active search/filter string.
|
||||||
|
* @property {number} total Unpaged count.
|
||||||
|
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||||
|
* @property {string|null} error
|
||||||
|
*/
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
brands: [],
|
||||||
|
filteredBrands: [],
|
||||||
|
filter: '',
|
||||||
|
total: 0,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Inflight Load Deduplication & Silent Reload
|
||||||
|
- Guard fetches with `let inflightLoad = null` to coalesce concurrent load/reload calls into a single HTTP promise.
|
||||||
|
- `load()` sets `status: 'loading'` for initial loads.
|
||||||
|
- `reload()` silently updates state without setting `status: 'loading'`, keeping existing data on screen to prevent flicker.
|
||||||
|
|
||||||
|
```js
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/** Initial load with visible loading state */
|
||||||
|
async load() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
brandAutomat.setState({ status: 'loading', error: null });
|
||||||
|
return (inflightLoad = fetchAllBrands()
|
||||||
|
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded' }))
|
||||||
|
.catch((err) => {
|
||||||
|
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => { inflightLoad = null; }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Silent re-fetch on SSE signal or background sync */
|
||||||
|
async reload() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
return (inflightLoad = fetchAllBrands()
|
||||||
|
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
|
||||||
|
.catch((err) => {
|
||||||
|
brandAutomat.setState({ error: err?.message ?? String(err) });
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => { inflightLoad = null; }));
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
brandAutomat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### D. Auth Lifecycle Coordination
|
||||||
|
Automatically tie the automat lifecycle to `authAutomat`:
|
||||||
|
```js
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
|
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
|
||||||
|
else if (auth.status === 'anon') brandAutomat.actions.reset();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E. SSE Echo Suppression
|
||||||
|
When handling backend SSE events, always verify `sender` against `getSseClientId()` to suppress redundant reloads for mutations dispatched by the current browser tab:
|
||||||
|
```js
|
||||||
|
sseAutomat.actions.on('brand', (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event?.data ?? '{}');
|
||||||
|
if (payload?.sender && payload.sender === getSseClientId()) return;
|
||||||
|
} catch { }
|
||||||
|
void brandAutomat.actions.reload().catch(() => { });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reference UI Component Pattern
|
||||||
|
|
||||||
|
Components consuming automats must follow the pattern established in [src/routes/artikel/marken.jsx](file:///home/seb/src/dashfront/src/routes/artikel/marken.jsx):
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { PureComponent } from 'react';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
|
||||||
|
export class ArtikelMarkenSearchField extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
// 1. Synchronous initial read — zero blank flash!
|
||||||
|
this.state = {
|
||||||
|
input: brandAutomat.state.filter ?? '',
|
||||||
|
submitting: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// 2. Fine-grained selector subscription
|
||||||
|
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||||
|
input: state.filter,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
// 3. Always clean up subscription
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onInputChange(input) {
|
||||||
|
this.setState({ input });
|
||||||
|
brandAutomat.actions.filter(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit() {
|
||||||
|
const name = this.state.input.trim();
|
||||||
|
if (!name || this.state.submitting) return;
|
||||||
|
|
||||||
|
this.setState({ submitting: true });
|
||||||
|
try {
|
||||||
|
const row = await brandAutomat.actions.create({ name });
|
||||||
|
enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' });
|
||||||
|
this.setState({ input: '', submitting: false });
|
||||||
|
brandAutomat.actions.filter('');
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ submitting: false });
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { input, submitting } = this.state;
|
||||||
|
return (
|
||||||
|
<SingleLineTextEdit
|
||||||
|
value={input}
|
||||||
|
disabled={submitting}
|
||||||
|
onChange={(next) => this.onInputChange(next)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Rules
|
||||||
|
1. **Always unsubscribe**: Save the return value of `automat.subscribe(this, ...)` in `this.unsub` and invoke `this.unsub?.()` in `componentWillUnmount()`.
|
||||||
|
2. **Synchronous constructor reads**: Read directly from `automat.state` or helper selector (e.g. `getArticle(sku)`) in the constructor.
|
||||||
|
3. **Selector efficiency**: The second argument to `subscribe(this, selector)` should return an object with only the slice of state needed by this component.
|
||||||
|
4. **No hooks in PureComponents**: Never call `useState`, `useEffect`, or `useMemo` inside `PureComponent`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI Library Integration (`dashgui` & MUI)
|
||||||
|
|
||||||
|
1. **Leverage `dashgui` Components**:
|
||||||
|
- `PanelTile` & `PanelTileGrid`: Container cards and responsive layouts.
|
||||||
|
- `SingleLineTextEdit`: Editable single-line text inputs (supports `search`, `clear`, `size="small"`).
|
||||||
|
- `RecordList`: High-performance list view for tabular and recordset data.
|
||||||
|
- `ElevatedPill`: Badges, chips, and status pills.
|
||||||
|
- `FillSpace`: Layout spacer.
|
||||||
|
2. **Theme Tokens & Color Constants**:
|
||||||
|
- Import shared palette constants from [src/colors.js](file:///home/seb/src/dashfront/src/colors.js) (`MAIN_BACKGROUND`, `PURPLE_PANEL`, etc.).
|
||||||
|
- Use standard MUI `Box`, `Button`, `Typography`, `CircularProgress` with `sx` props for layout spacing (`gap: 1`, `pb: 2`).
|
||||||
|
3. **Notifications**:
|
||||||
|
- Success: `enqueueSnackbar('Erfolgreich gespeichert', { variant: 'success' })` from `notistack`.
|
||||||
|
- Error: `notifyError(err)` from `src/utils/notify`.
|
||||||
|
- Page/Section Error Display: `<BackendError error={...} />`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Backend Communication & Pricing Rules
|
||||||
|
|
||||||
|
1. **API Namespaces**:
|
||||||
|
- Always import from `src/api` (`import { brand, article, delivery } from '../api'`).
|
||||||
|
- The `http()` client automatically sets `credentials: 'include'`.
|
||||||
|
- **Never pass `googleAuthId`** in request payloads; the backend extracts identity directly from the HTTP-only cookie.
|
||||||
|
2. **Pricing Convention**:
|
||||||
|
- Prices sent to and received from the API/database are **net prices** (`net`).
|
||||||
|
- Gross prices displayed to end users must be calculated using tax rates from `companyAutomat` (`selectCompanyTaxrate()`, `selectCompanyReducedTaxrate()`).
|
||||||
|
3. **Binary / Media Assets**:
|
||||||
|
- Compress images client-side into AVIF format (`@jsquash/avif`) before uploading.
|
||||||
|
- Use `ThumbHash` for instant blur placeholders before loading full images from `/api/thumb/{hash}` or `/api/image/{hash}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation Checklist for New Automats
|
||||||
|
|
||||||
|
When adding a new domain or configuration automat:
|
||||||
|
|
||||||
|
- [ ] Place file in [src/automats/](file:///home/seb/src/dashfront/src/automats/).
|
||||||
|
- [ ] Add JSDoc file header documenting **Upstream Triggers** (auth, SSE, UI).
|
||||||
|
- [ ] Model the state with JSDoc `@typedef`, including `status: 'idle'|'loading'|'succeeded'|'failed'` and `error`.
|
||||||
|
- [ ] Implement `inflightLoad` promise guard for request deduplication.
|
||||||
|
- [ ] Implement both `load()` (visible loading) and `reload()` (silent background refresh).
|
||||||
|
- [ ] Subscribe to `authAutomat` to auto-load on `'authed'` with `selectedCompanyId` and `reset()` on `'anon'`.
|
||||||
|
- [ ] Subscribe to `sseAutomat` with `getSseClientId()` echo check to trigger `actions.reload()`.
|
||||||
|
- [ ] Instantiate `new Automat(initialState, actions, { name: '...' })`.
|
||||||
|
- [ ] Export singleton as default and re-export in [src/automats/index.js](file:///home/seb/src/dashfront/src/automats/index.js).
|
||||||
1
.agents/skills/project-style
Symbolic link
1
.agents/skills/project-style
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
coding-style
|
||||||
@@ -149,7 +149,7 @@ src/
|
|||||||
In `dashfront`, two state management solutions coexist harmoniously:
|
In `dashfront`, two state management solutions coexist harmoniously:
|
||||||
|
|
||||||
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
|
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
|
||||||
- **Skill reference**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md).
|
- **Skill references**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md) and [.agents/skills/coding-style/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/coding-style/SKILL.md) (names [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) as the reference automat).
|
||||||
- Lives outside the React lifecycle in module scope (`src/automats/*.js`).
|
- Lives outside the React lifecycle in module scope (`src/automats/*.js`).
|
||||||
- Designed for React `PureComponent` to bypass unnecessary React reconciliations.
|
- Designed for React `PureComponent` to bypass unnecessary React reconciliations.
|
||||||
- **Pattern**:
|
- **Pattern**:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import sseAutomat, { getSseClientId } from './sseAutomat';
|
|||||||
/**
|
/**
|
||||||
* @typedef {Object} BrandState
|
* @typedef {Object} BrandState
|
||||||
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
||||||
|
* @property {Array<object>} filteredBrands Filtered brands according to active filter query.
|
||||||
|
* @property {string} filter Active search/filter string.
|
||||||
* @property {number} total Unpaged brand count.
|
* @property {number} total Unpaged brand count.
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||||
* @property {string|null} error
|
* @property {string|null} error
|
||||||
@@ -30,139 +32,119 @@ import sseAutomat, { getSseClientId } from './sseAutomat';
|
|||||||
/** @type {BrandState} */
|
/** @type {BrandState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
brands: [],
|
brands: [],
|
||||||
|
filteredBrands: [],
|
||||||
|
filter: '',
|
||||||
total: 0,
|
total: 0,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function sortBrands(brands) {
|
const sortBrands = (brands) =>
|
||||||
return [...brands].sort((a, b) =>
|
[...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||||
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
|
|
||||||
);
|
const applyFilter = (brands, filter) => {
|
||||||
}
|
const query = String(filter ?? '').trim().toLowerCase();
|
||||||
|
return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands;
|
||||||
|
};
|
||||||
|
|
||||||
let brandAutomat;
|
let brandAutomat;
|
||||||
|
|
||||||
/** In-flight `load()`/`reload()` promise so concurrent callers share one request. */
|
|
||||||
let inflightLoad = null;
|
let inflightLoad = null;
|
||||||
|
|
||||||
/** Max page size the API accepts; full list is fetched by paging through. */
|
|
||||||
const FETCH_PAGE_SIZE = 100;
|
const FETCH_PAGE_SIZE = 100;
|
||||||
|
|
||||||
async function fetchAllBrands() {
|
async function fetchAllBrands() {
|
||||||
let offset = 0;
|
let offset = 0, total = Infinity;
|
||||||
let total = Infinity;
|
|
||||||
const collected = [];
|
const collected = [];
|
||||||
for (; ;) {
|
while (offset === 0 || collected.length < total) {
|
||||||
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
||||||
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
||||||
const page = Array.isArray(res.brands) ? res.brands : [];
|
const page = Array.isArray(res.brands) ? res.brands : [];
|
||||||
collected.push(...page);
|
collected.push(...page);
|
||||||
offset += page.length;
|
offset += page.length;
|
||||||
if (page.length === 0 || collected.length >= total) break;
|
if (page.length === 0) break;
|
||||||
}
|
}
|
||||||
return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length };
|
return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateStateWithBrands = (brands, extra = {}) => {
|
||||||
|
const filteredBrands = applyFilter(brands, brandAutomat.state.filter);
|
||||||
|
brandAutomat.setState({ brands, filteredBrands, total: brands.length, ...extra });
|
||||||
|
return brands;
|
||||||
|
};
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
/** Sets the active brand search filter and updates filteredBrands. */
|
||||||
|
filter(query) {
|
||||||
|
const filter = String(query ?? '');
|
||||||
|
brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) });
|
||||||
|
},
|
||||||
|
|
||||||
/** Load all brands for the selected company (pages through the API). */
|
/** Load all brands for the selected company (pages through the API). */
|
||||||
async load() {
|
async load() {
|
||||||
if (inflightLoad) return inflightLoad;
|
if (inflightLoad) return inflightLoad;
|
||||||
brandAutomat.setState({ status: 'loading', error: null });
|
brandAutomat.setState({ status: 'loading', error: null });
|
||||||
inflightLoad = (async () => {
|
return (inflightLoad = fetchAllBrands()
|
||||||
try {
|
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded' }))
|
||||||
const { brands, total } = await fetchAllBrands();
|
.catch((err) => {
|
||||||
brandAutomat.setState({ brands, total, status: 'succeeded' });
|
|
||||||
return brands;
|
|
||||||
} catch (err) {
|
|
||||||
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
})
|
||||||
inflightLoad = null;
|
.finally(() => { inflightLoad = null; }));
|
||||||
}
|
|
||||||
})();
|
|
||||||
return inflightLoad;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Silently re-fetches all brands while keeping current state visible. */
|
||||||
* Silently re-fetches all brands while keeping the current state
|
|
||||||
* visible (no loading flash). Subscribed views re-render from the updated
|
|
||||||
* state — this is the reload pattern for SSE-driven refreshes.
|
|
||||||
*
|
|
||||||
* @returns {Promise<Array<object>>} Fresh brands.
|
|
||||||
*/
|
|
||||||
async reload() {
|
async reload() {
|
||||||
if (inflightLoad) return inflightLoad;
|
if (inflightLoad) return inflightLoad;
|
||||||
inflightLoad = (async () => {
|
return (inflightLoad = fetchAllBrands()
|
||||||
try {
|
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
|
||||||
const { brands, total } = await fetchAllBrands();
|
.catch((err) => {
|
||||||
brandAutomat.setState({ brands, total, status: 'succeeded', error: null });
|
|
||||||
return brands;
|
|
||||||
} catch (err) {
|
|
||||||
brandAutomat.setState({ error: err?.message ?? String(err) });
|
brandAutomat.setState({ error: err?.message ?? String(err) });
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
})
|
||||||
inflightLoad = null;
|
.finally(() => { inflightLoad = null; }));
|
||||||
}
|
|
||||||
})();
|
|
||||||
return inflightLoad;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a brand (insert). 409 Brand already exists on duplicate name.
|
* Returns the brand state, loading brands first if uninitialized.
|
||||||
* @param {{ name: string, gsprpinfo?: string }} body
|
*
|
||||||
|
* @returns {Promise<BrandState>} Current brand state.
|
||||||
*/
|
*/
|
||||||
|
async getOrLoad() {
|
||||||
|
if (brandAutomat.state.status !== 'succeeded') {
|
||||||
|
await brandAutomat.actions.reload();
|
||||||
|
}
|
||||||
|
return brandAutomat.state;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Create a brand (insert). */
|
||||||
async create(body) {
|
async create(body) {
|
||||||
const res = await brandApi.create(body);
|
const res = await brandApi.create(body);
|
||||||
const row = res.brand;
|
const row = res.brand;
|
||||||
const brands = sortBrands([
|
updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row]));
|
||||||
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
|
|
||||||
row,
|
|
||||||
]);
|
|
||||||
brandAutomat.setState({ brands, total: brands.length });
|
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Rename a brand. */
|
||||||
* Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`.
|
|
||||||
* @param {number} id
|
|
||||||
* @param {{ name?: string, gsprpinfo?: string }} body
|
|
||||||
*/
|
|
||||||
async rename(id, body) {
|
async rename(id, body) {
|
||||||
const res = await brandApi.rename(id, body);
|
const res = await brandApi.rename(id, body);
|
||||||
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
||||||
const brands = sortBrands(
|
updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b))));
|
||||||
brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)),
|
|
||||||
);
|
|
||||||
brandAutomat.setState({ brands });
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Delete a brand by id. */
|
||||||
* Delete a brand by id.
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
async remove(id) {
|
async remove(id) {
|
||||||
await brandApi.delete(id);
|
await brandApi.delete(id);
|
||||||
const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
|
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
|
||||||
brandAutomat.setState({ brands, total: brands.length });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Add / update brand image. */
|
||||||
* Add / update brand image.
|
|
||||||
* @param {number} id
|
|
||||||
* @param {Blob|File} file
|
|
||||||
*/
|
|
||||||
async addImage(id, file) {
|
async addImage(id, file) {
|
||||||
const res = await brandApi.addImage(id, file);
|
const res = await brandApi.addImage(id, file);
|
||||||
await brandAutomat.actions.reload();
|
await brandAutomat.actions.reload();
|
||||||
return res.brand;
|
return res.brand;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/** Delete brand image. */
|
||||||
* Delete brand image.
|
|
||||||
* @param {number} id
|
|
||||||
*/
|
|
||||||
async deleteImage(id) {
|
async deleteImage(id) {
|
||||||
const res = await brandApi.deleteImage(id);
|
const res = await brandApi.deleteImage(id);
|
||||||
await brandAutomat.actions.reload();
|
await brandAutomat.actions.reload();
|
||||||
@@ -177,36 +159,21 @@ const actions = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Global brand state container.
|
* Global brand state container.
|
||||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
|
||||||
*
|
|
||||||
* @type {Automat<BrandState>}
|
* @type {Automat<BrandState>}
|
||||||
*/
|
*/
|
||||||
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
|
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
|
||||||
|
|
||||||
// Subscribe to auth changes
|
|
||||||
authAutomat.subscribe((auth) => {
|
authAutomat.subscribe((auth) => {
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
if (auth.status === 'authed' && companyId) {
|
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
|
||||||
void brandAutomat.actions.load();
|
else if (auth.status === 'anon') brandAutomat.actions.reset();
|
||||||
} else if (auth.status === 'anon') {
|
|
||||||
brandAutomat.actions.reset();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE: single `brand` event with `{ sender, brandId }` payload for every
|
|
||||||
// create/rename/delete/picture change. Silently reloads the list; subscribed
|
|
||||||
// views re-render from state. Own events are skipped.
|
|
||||||
sseAutomat.actions.on('brand', (event) => {
|
sseAutomat.actions.on('brand', (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event?.data ?? '{}');
|
const payload = JSON.parse(event?.data ?? '{}');
|
||||||
if (payload && typeof payload.sender === 'string' && payload.sender) {
|
if (payload?.sender && payload.sender === getSseClientId()) return;
|
||||||
try {
|
} catch { }
|
||||||
if (payload.sender === getSseClientId()) return;
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Unparseable payload: fall through to reload.
|
|
||||||
}
|
|
||||||
void brandAutomat.actions.reload().catch(() => { });
|
void brandAutomat.actions.reload().catch(() => { });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ListItem from '@mui/material/ListItem';
|
|||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import brandAutomat from '../../automats/brandAutomat';
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -128,7 +129,7 @@ class ArtikelMarke extends PureComponent {
|
|||||||
'&:hover': disabled ? undefined : { opacity: 0.8 },
|
'&:hover': disabled ? undefined : { opacity: 0.8 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BrandThumb brand={brand}/>
|
<BrandThumb brand={brand} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -141,7 +142,8 @@ class ArtikelMarke extends PureComponent {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SingleLineTextEdit
|
|
||||||
|
{true ? (<SingleLineTextEdit
|
||||||
size="small"
|
size="small"
|
||||||
label=""
|
label=""
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -151,6 +153,7 @@ class ArtikelMarke extends PureComponent {
|
|||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
sx={{ flex: 1, minWidth: 0 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
|
) : (<Typography sx={{ flex: 1, px: 1.6, py: 0.9, borderRadius: 1, border: '1px solid rgba(0, 0, 0, 0.23)' }}>{draft}</Typography>)}
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={`„${name}" löschen`}
|
aria-label={`„${name}" löschen`}
|
||||||
|
|||||||
@@ -23,21 +23,25 @@ class ArtikelMarkenSearchField extends PureComponent {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
input: props.value ?? '',
|
input: brandAutomat.state.filter ?? '',
|
||||||
submitting: false,
|
submitting: false,
|
||||||
};
|
};
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidMount() {
|
||||||
if (prevProps.value !== this.props.value && this.props.value !== this.state.input) {
|
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||||
this.setState({ input: this.props.value ?? '' });
|
input: state.filter,
|
||||||
}
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChange(input) {
|
onInputChange(input) {
|
||||||
this.setState({ input });
|
this.setState({ input });
|
||||||
this.props.onChange?.(input);
|
brandAutomat.actions.filter(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmit() {
|
async handleSubmit() {
|
||||||
@@ -49,7 +53,7 @@ class ArtikelMarkenSearchField extends PureComponent {
|
|||||||
const row = await brandAutomat.actions.create({ name });
|
const row = await brandAutomat.actions.create({ name });
|
||||||
enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' });
|
enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' });
|
||||||
this.setState({ input: '', submitting: false });
|
this.setState({ input: '', submitting: false });
|
||||||
this.props.onChange?.('');
|
brandAutomat.actions.filter('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ submitting: false });
|
this.setState({ submitting: false });
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -105,7 +109,7 @@ class ArtikelMarkenPage extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
status: brandAutomat.state.status,
|
status: brandAutomat.state.status,
|
||||||
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
||||||
brands: brandAutomat.state.brands
|
brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands,
|
||||||
};
|
};
|
||||||
this.searchRef = createRef();
|
this.searchRef = createRef();
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
@@ -115,7 +119,7 @@ class ArtikelMarkenPage extends PureComponent {
|
|||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
|
|
||||||
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||||
brands: state.brands,
|
brands: state.filteredBrands,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
error: state.error ? backendErrorFrom({ message: state.error }) : null,
|
error: state.error ? backendErrorFrom({ message: state.error }) : null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user