u
This commit is contained in:
190
src/automats/artikelAutomat.js
Normal file
190
src/automats/artikelAutomat.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArtikelState
|
||||
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
|
||||
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
|
||||
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
|
||||
*/
|
||||
|
||||
/** @type {ArtikelState} */
|
||||
const initialState = {
|
||||
bySku: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
|
||||
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
|
||||
*
|
||||
* @type {Map<string, Promise<object>>}
|
||||
*/
|
||||
const inflight = new Map();
|
||||
|
||||
/** @type {Automat<ArtikelState>} */
|
||||
let artikelAutomat;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads an article into the shared cache, deduplicating concurrent callers.
|
||||
* Resolves with the cached article when it is already present.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {Promise<object>} Loaded article detail.
|
||||
*/
|
||||
loadArticle(sku) {
|
||||
const key = String(sku ?? '').trim();
|
||||
if (!key) {
|
||||
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
|
||||
}
|
||||
|
||||
const cached = artikelAutomat.state.bySku[key];
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = articleApi
|
||||
.getBySku(key)
|
||||
.then((res) => {
|
||||
requireObject(res, 'GET /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
requireDefined(res.article.sku, 'article.sku');
|
||||
return artikelAutomat.actions.setArticle(res.article);
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches an article, keeping the cached copy until the new one
|
||||
* arrives. Use for SSE invalidation and pull-to-refresh.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {Promise<object>} Fresh article detail.
|
||||
*/
|
||||
async reloadArticle(sku) {
|
||||
const key = String(sku ?? '').trim();
|
||||
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
|
||||
|
||||
const res = await articleApi.getBySku(key);
|
||||
requireObject(res, 'GET /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
requireDefined(res.article.sku, 'article.sku');
|
||||
return artikelAutomat.actions.setArticle(res.article);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
|
||||
* response so every subscriber sees the same object.
|
||||
*
|
||||
* @param {object} article Article detail with at least a `sku`.
|
||||
* @returns {object} The stored article.
|
||||
*/
|
||||
setArticle(article) {
|
||||
requireObject(article, 'article');
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
artikelAutomat.setState({
|
||||
bySku: { ...artikelAutomat.state.bySku, [key]: article },
|
||||
});
|
||||
return article;
|
||||
},
|
||||
|
||||
/**
|
||||
* Patches the cached article name without a round-trip (optimistic update).
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {string} name New name.
|
||||
* @returns {object|null} Updated article, or `null` when not cached.
|
||||
*/
|
||||
setArticleName(sku, name) {
|
||||
const key = String(sku);
|
||||
const current = artikelAutomat.state.bySku[key];
|
||||
if (!current) return null;
|
||||
return artikelAutomat.actions.setArticle({ ...current, name });
|
||||
},
|
||||
|
||||
/**
|
||||
* Drops an article from the cache (e.g. after deletion), forcing the next
|
||||
* `loadArticle` to hit the backend.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
clearArticle(sku) {
|
||||
const key = String(sku);
|
||||
if (!(key in artikelAutomat.state.bySku)) return;
|
||||
const bySku = { ...artikelAutomat.state.bySku };
|
||||
delete bySku[key];
|
||||
artikelAutomat.setState({ bySku });
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets every cached article.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
artikelAutomat.setState({ bySku: {} });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared cache for article detail payloads. State lives in module scope so
|
||||
* navigating between articles (and remounting the view) reuses the cache
|
||||
* instead of re-fetching.
|
||||
*
|
||||
* @type {Automat<ArtikelState>}
|
||||
*/
|
||||
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
|
||||
|
||||
/**
|
||||
* Reads a cached article synchronously.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {object|null} Cached article detail, or `null`.
|
||||
*/
|
||||
export function getArticle(sku) {
|
||||
if (sku == null) return null;
|
||||
return artikelAutomat.state.bySku[String(sku)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an article detail payload is currently cached.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {boolean} `true` when cached.
|
||||
*/
|
||||
export function hasArticle(sku) {
|
||||
return getArticle(sku) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a selector for one SKU, so a component can subscribe to just that
|
||||
* article instead of every cache change.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {(state: ArtikelState) => object|null} Selector function.
|
||||
*/
|
||||
export function createArticleSelector(sku) {
|
||||
const key = sku == null ? null : String(sku);
|
||||
return (state) => (key == null ? null : state.bySku[key] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
|
||||
*/
|
||||
export function selectArticle(sku) {
|
||||
return artikelAutomat.select(createArticleSelector(sku));
|
||||
}
|
||||
|
||||
export default artikelAutomat;
|
||||
@@ -3,4 +3,13 @@ export { default as authAutomat } from './authAutomat';
|
||||
export * from './authAutomat';
|
||||
export { default as navigationAutomat } from './navigationAutomat';
|
||||
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
|
||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
||||
export { setViewerPosition } from './userSettingsAutomat';
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as artikelAutomat } from './artikelAutomat';
|
||||
export {
|
||||
getArticle,
|
||||
hasArticle,
|
||||
selectArticle,
|
||||
createArticleSelector,
|
||||
} from './artikelAutomat';
|
||||
|
||||
48
src/automats/userSettingsAutomat.js
Normal file
48
src/automats/userSettingsAutomat.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Automat } from 'react-automat';
|
||||
|
||||
/**
|
||||
* @typedef {'top'|'right'|'below'} ViewerPosition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserSettingsState
|
||||
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
||||
* attachment viewer (text editor / PDF preview) relative to the article list.
|
||||
*/
|
||||
|
||||
/** @type {UserSettingsState} */
|
||||
const initialState = {
|
||||
viewerPosition: 'top',
|
||||
};
|
||||
|
||||
/**
|
||||
* Persisted per-user UI preferences. Unlike the other automats this one is
|
||||
* backed by IndexedDB (`persist: true`) so settings survive reloads and
|
||||
* sessions.
|
||||
*
|
||||
* @type {Automat<UserSettingsState>}
|
||||
*/
|
||||
const userSettingsAutomat = new Automat(
|
||||
initialState,
|
||||
{
|
||||
/**
|
||||
* Set the docking position of the attachment viewer.
|
||||
*
|
||||
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
|
||||
* @returns {void}
|
||||
*/
|
||||
setViewerPosition(position) {
|
||||
if (position !== 'top' && position !== 'right' && position !== 'below') {
|
||||
return;
|
||||
}
|
||||
userSettingsAutomat.setState({ viewerPosition: position });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userSettings',
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export default userSettingsAutomat;
|
||||
export const { setViewerPosition } = userSettingsAutomat.actions;
|
||||
Reference in New Issue
Block a user