This commit is contained in:
seb
2026-10-11 02:36:46 +02:00
parent ce072a3731
commit 3379a63baf
6 changed files with 356 additions and 105 deletions

View 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).

View File

@@ -0,0 +1 @@
coding-style

View File

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

View File

@@ -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(() => { });
}); });

View File

@@ -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`}

View File

@@ -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,
})); }));