413 lines
14 KiB
JavaScript
413 lines
14 KiB
JavaScript
/**
|
|
* @file categoryViewAutomat.js
|
|
* @description Owns the per-category view automats (`categoryViews`), a
|
|
* `KeyedAutomat` keyed by category slug path. Each entry holds one category's
|
|
* articles (`GET /api/category/:id/articles`) plus its child categories, and is
|
|
* resolved from the category slug path against the loaded category tree.
|
|
*
|
|
* This file holds ONLY the keyed per-category views. The category tree itself
|
|
* (`categories` map, `rootCategoryId`, category/article mutations) lives in
|
|
* `categoryAutomat.js`; this module receives it through `attachCategoryTree()`
|
|
* so the dependency stays one-directional (views → tree).
|
|
*
|
|
* Upstream Triggers:
|
|
* - `categoryAutomat`: The tree automat attaches itself via `attachCategoryTree()`
|
|
* and calls `loadAllCategoryViews()` once `treeStatus === 'succeeded'`,
|
|
* `reloadLoadedCategoryViews()` when the tree changes, and
|
|
* `disposeAllCategoryViewAutomats()` on `reset()` (logout / company switch).
|
|
* - `sseAutomat`: Calls `invalidateAllCategoryViews()` on a `'category'` event and
|
|
* on an `'articles'` event for the open views.
|
|
* - UI / Routes: `categoryAutomat.key(slugPath)` /
|
|
* `categoryAutomat.read(slugPath)` / `categoryAutomat.load(slugPath)` facades.
|
|
*
|
|
* Downstream Triggers:
|
|
* - Routes / components: `src/routes/artikel/kategorien.jsx` (grid + drag & drop),
|
|
* `src/components/category/CategoryTree.jsx`.
|
|
*/
|
|
|
|
import { KeyedAutomat } from 'react-automat';
|
|
import { category as categoryApi } from '../api';
|
|
import authAutomat from './authAutomat';
|
|
import {
|
|
getCategoryBreadcrumbs,
|
|
getCategoryChildren,
|
|
cleanCategorySlugPath,
|
|
resolveCategoryIdFromSlugPath,
|
|
} from '../utils/categoryUtils';
|
|
|
|
/**
|
|
* @typedef {Object} CategoryViewData
|
|
* @property {string} slug Cleaned slug path this view was keyed by.
|
|
* @property {number|string} categoryId Resolved category id.
|
|
* @property {Array<object>} articles Articles in that category.
|
|
* @property {Array<object>} childCategories Direct child category nodes.
|
|
*/
|
|
|
|
/**
|
|
* The tree automat, injected by `categoryAutomat.js` at module-evaluation time.
|
|
* @type {import('react-automat').Automat<object>|null}
|
|
*/
|
|
let treeAutomat = null;
|
|
|
|
/**
|
|
* Injects the category tree automat this module resolves slug paths against.
|
|
*
|
|
* @param {import('react-automat').Automat<object>} automat Tree automat.
|
|
* @returns {void}
|
|
*/
|
|
export function attachCategoryTree(automat) {
|
|
treeAutomat = automat;
|
|
}
|
|
|
|
/**
|
|
* Returns the attached tree automat.
|
|
*
|
|
* @returns {import('react-automat').Automat<object>} Tree automat.
|
|
* @throws {Error} When the tree automat has not been attached yet.
|
|
*/
|
|
function tree() {
|
|
if (!treeAutomat) {
|
|
throw new Error('categoryAutomat has not attached the category tree yet.');
|
|
}
|
|
return treeAutomat;
|
|
}
|
|
|
|
/**
|
|
* Resolves once the attached category tree has loaded successfully.
|
|
*
|
|
* Rejects when the tree load fails or the session becomes anonymous, so callers
|
|
* waiting on a view never hang forever.
|
|
*
|
|
* @returns {Promise<object>} Tree state with `treeStatus === 'succeeded'`.
|
|
*/
|
|
export function waitForCategoryTree() {
|
|
const current = tree().state;
|
|
if (current.treeStatus === 'succeeded') return Promise.resolve(current);
|
|
return new Promise((resolve, reject) => {
|
|
let unsubTree = null;
|
|
let unsubAuth = null;
|
|
const cleanup = () => {
|
|
unsubTree?.();
|
|
unsubAuth?.();
|
|
unsubTree = null;
|
|
unsubAuth = null;
|
|
};
|
|
unsubTree = tree().subscribe((state) => {
|
|
if (state.treeStatus === 'succeeded') {
|
|
cleanup();
|
|
resolve(state);
|
|
} else if (state.treeStatus === 'failed') {
|
|
cleanup();
|
|
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
|
|
}
|
|
});
|
|
unsubAuth = authAutomat.subscribe((auth) => {
|
|
if (auth.status === 'anon') {
|
|
cleanup();
|
|
reject(new Error('Nicht authentifiziert.'));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Per-route category view stores, keyed by cleaned category slug path.
|
|
*
|
|
* @type {KeyedAutomat<CategoryViewData>}
|
|
*/
|
|
export const categoryViews = new KeyedAutomat(
|
|
null,
|
|
{},
|
|
{
|
|
name: 'category-views',
|
|
loader: async (slugPath) => {
|
|
const slug = cleanCategorySlugPath(slugPath);
|
|
|
|
// The tree must be loaded before a slug path can be resolved to an id.
|
|
const treeState = tree().state;
|
|
if (treeState.treeStatus !== 'succeeded') {
|
|
if (treeState.treeStatus === 'idle') {
|
|
void tree().actions.loadTree();
|
|
}
|
|
await waitForCategoryTree();
|
|
}
|
|
|
|
const { categories, rootCategoryId } = tree().state;
|
|
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
|
|
if (categoryId == null) {
|
|
throw new Error(`Kategorie für Pfad „${slug}“ nicht gefunden.`);
|
|
}
|
|
|
|
const res = await categoryApi.getArticles(categoryId);
|
|
const articles = Array.isArray(res.articles) ? res.articles : [];
|
|
const childCategories = getCategoryChildren(categories, categoryId);
|
|
|
|
return { slug, categoryId, articles, childCategories };
|
|
},
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Runs `worker` over `items` with a bounded number of concurrent calls.
|
|
* Failures are captured per item instead of rejecting the whole batch.
|
|
*
|
|
* @template T, R
|
|
* @param {Array<T>} items Work items.
|
|
* @param {number} limit Maximum concurrent workers.
|
|
* @param {(item: T, index: number) => Promise<R>} worker Async work per item.
|
|
* @returns {Promise<Array<{ok: true, value: R}|{ok: false, error: any}>>} Ordered results.
|
|
*/
|
|
async function mapWithConcurrency(items, limit, worker) {
|
|
const results = new Array(items.length);
|
|
let cursor = 0;
|
|
|
|
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
while (cursor < items.length) {
|
|
const index = cursor++;
|
|
try {
|
|
results[index] = { ok: true, value: await worker(items[index], index) };
|
|
} catch (error) {
|
|
results[index] = { ok: false, error };
|
|
}
|
|
}
|
|
});
|
|
|
|
await Promise.all(runners);
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Builds the slug path for a category from its breadcrumb trail.
|
|
*
|
|
* @param {Record<string,object>} categories id → node map.
|
|
* @param {number|string} rootCategoryId Root category id.
|
|
* @param {number|string} categoryId Category id.
|
|
* @returns {string} Slug path usable as a `categoryViews` key.
|
|
*/
|
|
export function categoryViewSlugPath(categories, rootCategoryId, categoryId) {
|
|
return getCategoryBreadcrumbs(categories, categoryId, rootCategoryId)
|
|
.map((crumb) => crumb.slug)
|
|
.filter(Boolean)
|
|
.join('/');
|
|
}
|
|
|
|
/**
|
|
* Collects the data of every currently loaded category view.
|
|
*
|
|
* Used by `articleIndexAutomat` to derive the search index from the view cache
|
|
* without issuing additional requests.
|
|
*
|
|
* @returns {Array<CategoryViewData>} Loaded view payloads.
|
|
*/
|
|
export function collectLoadedCategoryViewData() {
|
|
const out = [];
|
|
for (const item of Object.values(categoryViews.state)) {
|
|
if (item?.loaded && item.data) out.push(item.data);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Loads a view for **every** category in the loaded tree.
|
|
*
|
|
* Called by `categoryAutomat` once the tree finished loading, so navigating to
|
|
* any category (and the derived article index) resolves from cache instead of
|
|
* waiting for a round-trip. Already-loaded views are skipped.
|
|
*
|
|
* @param {{ concurrency?: number }} [options] Load tuning.
|
|
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
|
|
*/
|
|
export async function loadAllCategoryViews({ concurrency = 6 } = {}) {
|
|
const treeState = tree().state;
|
|
if (treeState.treeStatus !== 'succeeded') {
|
|
return { loaded: 0, failed: [] };
|
|
}
|
|
|
|
const { categories, rootCategoryId } = treeState;
|
|
const slugs = [];
|
|
const seen = new Set();
|
|
|
|
for (const node of Object.values(categories ?? {})) {
|
|
const slug = categoryViewSlugPath(categories, rootCategoryId, node.id);
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
slugs.push(slug);
|
|
}
|
|
|
|
const results = await mapWithConcurrency(slugs, concurrency, (slug) =>
|
|
// Force only when not already cached; `load()` resolves immediately otherwise.
|
|
categoryViews.load(slug),
|
|
);
|
|
|
|
const failed = [];
|
|
let loaded = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.ok) loaded += 1;
|
|
else failed.push({ slug: slugs[index], error: result.error });
|
|
});
|
|
|
|
return { loaded, failed };
|
|
}
|
|
|
|
/**
|
|
* Re-fetches only the views that are already cached, keeping the set of cached
|
|
* categories small. Used for SSE-driven refreshes.
|
|
*
|
|
* @returns {Promise<void>} Resolves once all cached views settled.
|
|
*/
|
|
export async function reloadLoadedCategoryViews() {
|
|
await categoryViews.setDirty(undefined, false, true);
|
|
}
|
|
|
|
/**
|
|
* Background-invalidates all loaded category views.
|
|
*
|
|
* @returns {Promise<unknown[]>} Per-view reload promises.
|
|
*/
|
|
export function invalidateAllCategoryViews() {
|
|
return categoryViews.setDirty(undefined, false, true);
|
|
}
|
|
|
|
/**
|
|
* Disposes every category view (logout / company switch).
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
export function disposeAllCategoryViewAutomats() {
|
|
categoryViews.clear();
|
|
}
|
|
|
|
/**
|
|
* Get or create the view handle for a slug path (backward compatible helper).
|
|
*
|
|
* @param {{ slugPath: string }} param0
|
|
* @returns {import('react-automat').KeyHandle<CategoryViewData>} Keyed view handle.
|
|
*/
|
|
export function getOrCreateCategoryAutomat({ slugPath }) {
|
|
return categoryViews.key(cleanCategorySlugPath(slugPath));
|
|
}
|
|
|
|
/**
|
|
* Re-derives `childCategories` for every loaded view after the tree changed.
|
|
*
|
|
* @param {Record<string,object>|null} categories Fresh id → node map.
|
|
* @returns {void}
|
|
*/
|
|
export function refreshCategoryViewChildren(categories) {
|
|
if (!categories) return;
|
|
for (const [slug, item] of categoryViews._items.entries()) {
|
|
if (item.data && item.data.categoryId != null) {
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
childCategories: getCategoryChildren(categories, item.data.categoryId),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends newly created articles to every loaded view of `categoryId`.
|
|
*
|
|
* @param {{ categoryId: number|string, articles: Array<object> }} param0
|
|
* @returns {void}
|
|
*/
|
|
export function appendArticlesToCategoryViews({ categoryId, articles }) {
|
|
if (!articles?.length || categoryId == null) return;
|
|
for (const [slug, item] of categoryViews._items.entries()) {
|
|
if (item.data && String(item.data.categoryId) === String(categoryId)) {
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
articles: [...(item.data.articles || []), ...articles],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates an article in all loaded category views.
|
|
*
|
|
* @param {object} article Article detail or row with at least `sku`.
|
|
* @returns {void}
|
|
*/
|
|
export function updateArticleInCategoryViews(article) {
|
|
if (!article || article.sku == null) return;
|
|
const sku = String(article.sku);
|
|
const targetCategoryIds = Array.isArray(article.categoryIds)
|
|
? new Set(article.categoryIds.map(String))
|
|
: null;
|
|
|
|
for (const [slug, item] of categoryViews._items.entries()) {
|
|
if (!item?.data) continue;
|
|
const viewCatId = String(item.data.categoryId);
|
|
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
|
|
|
|
if (existingIndex >= 0) {
|
|
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
|
|
// Article moved out of this category
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
|
|
});
|
|
} else {
|
|
// Update in-place
|
|
const existing = item.data.articles[existingIndex];
|
|
const updatedArticles = [...item.data.articles];
|
|
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
|
const hasPhotosProp = 'photos' in article;
|
|
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
|
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
|
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
|
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
|
|
|
updatedArticles[existingIndex] = {
|
|
...existing,
|
|
...article,
|
|
name: article.name ?? existing.name,
|
|
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
|
|
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
|
|
stock: article.stock ?? existing.stock,
|
|
available: article.available ?? existing.available,
|
|
warehouses: article.warehouses ?? existing.warehouses,
|
|
brand: article.brand ?? existing.brand,
|
|
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
|
childCount: article.childCount ?? existing.childCount,
|
|
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
|
|
fullHash,
|
|
thumbHash,
|
|
photos,
|
|
smallThumbBinary,
|
|
};
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
articles: updatedArticles,
|
|
});
|
|
}
|
|
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
|
|
// Article added to this category
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
articles: [...(item.data.articles || []), article],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a deleted article from every loaded view that contains it.
|
|
*
|
|
* @param {string} sku Article SKU.
|
|
* @returns {void}
|
|
*/
|
|
export function removeArticleBySku(sku) {
|
|
if (!sku) return;
|
|
for (const [slug, item] of categoryViews._items.entries()) {
|
|
if (item.data?.articles?.some((r) => r.sku === sku)) {
|
|
categoryViews.key(slug).set({
|
|
...item.data,
|
|
articles: item.data.articles.filter((r) => r.sku !== sku),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export default categoryViews; |