no redux
This commit is contained in:
@@ -16,13 +16,8 @@ import {
|
||||
* @property {string|null} languageId
|
||||
* @property {Record<string,object>|null} categories id → node map
|
||||
* @property {number|string|null} rootCategoryId
|
||||
* @property {Array<object>} rootArticles
|
||||
* @property {number|string|null} currentCategoryId
|
||||
* @property {Array<object>} articles
|
||||
* @property {number} articlesTotal
|
||||
* @property {boolean} renameMode
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
@@ -32,25 +27,117 @@ const initialState = {
|
||||
languageId: null,
|
||||
categories: null,
|
||||
rootCategoryId: null,
|
||||
rootArticles: [],
|
||||
currentCategoryId: null,
|
||||
articles: [],
|
||||
articlesTotal: 0,
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
articlesStatus: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let categoryAutomat;
|
||||
|
||||
// ─── Pure helpers ──────────────────────────────────────────────────────────────
|
||||
// Registry of active per-route category view automats
|
||||
const categoryViewInstances = new Set();
|
||||
// Reverse index for O(1) parentId lookup to target route automat
|
||||
const categoryIdToRouteName = new Map();
|
||||
|
||||
function articlesForCategory(state, categoryId) {
|
||||
if (String(categoryId) === String(state.rootCategoryId)) {
|
||||
return state.rootArticles;
|
||||
/**
|
||||
* Loader for category view automat: fetches articles for categoryId.
|
||||
* Promise only resolves when all articles are loaded.
|
||||
*/
|
||||
async function fetchCategoryArticles(automat) {
|
||||
try {
|
||||
const res = await categoryApi.getArticles(automat.state.categoryId);
|
||||
const articles = Array.isArray(res.articles) ? res.articles : [];
|
||||
return { articles, loaded: true, loading: false, error: null };
|
||||
} catch (err) {
|
||||
const errorMsg = err?.message ?? String(err);
|
||||
automat.setState({ loading: false, loaded: false, error: errorMsg });
|
||||
throw err;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a per-route category view automat for the given slug and categoryId.
|
||||
* Returns null if categoryId is null.
|
||||
*
|
||||
* @param {{ slugPath?: string, categoryId: number|string|null }} param0
|
||||
* @returns {Automat|null}
|
||||
*/
|
||||
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
|
||||
if (categoryId == null) return null;
|
||||
|
||||
const cleanSlug = (slugPath ?? '').replace(/^\/+|\/+$/g, '');
|
||||
const name = cleanSlug === '' ? 'category-view:root' : `category-view:${cleanSlug}`;
|
||||
|
||||
const existing = Automat.get(name);
|
||||
if (existing) {
|
||||
if (String(existing.state?.categoryId) === String(categoryId)) {
|
||||
categoryViewInstances.add(existing);
|
||||
categoryIdToRouteName.set(String(categoryId), name);
|
||||
return existing;
|
||||
}
|
||||
// Stale category ID guard: ID changed, dispose old automat
|
||||
try {
|
||||
existing.dispose();
|
||||
} catch {}
|
||||
categoryViewInstances.delete(existing);
|
||||
}
|
||||
|
||||
const automat = new Automat(
|
||||
{
|
||||
slug: cleanSlug,
|
||||
categoryId,
|
||||
articles: [],
|
||||
loaded: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
},
|
||||
(inst) => ({
|
||||
reload: () => inst.setDirty({ keep: true, now: true }),
|
||||
retry: () => inst.setDirty({ keep: false, now: true }),
|
||||
}),
|
||||
{
|
||||
name,
|
||||
loader: fetchCategoryArticles,
|
||||
}
|
||||
);
|
||||
automat.reload = () => automat.setDirty({ keep: true, now: true });
|
||||
automat.retry = () => automat.setDirty({ keep: false, now: true });
|
||||
|
||||
categoryViewInstances.add(automat);
|
||||
categoryIdToRouteName.set(String(categoryId), name);
|
||||
return automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all active category view automats by triggering background reload.
|
||||
*/
|
||||
export function invalidateAllCategoryViews() {
|
||||
for (const automat of categoryViewInstances) {
|
||||
if (typeof automat.reload === 'function') {
|
||||
automat.reload().catch((err) => {
|
||||
console.error('[categoryAutomat] reload view failed', err);
|
||||
});
|
||||
} else if (typeof automat.actions?.reload === 'function') {
|
||||
automat.actions.reload().catch((err) => {
|
||||
console.error('[categoryAutomat] reload view failed', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all category view automats and clear registries (e.g. on logout or company switch).
|
||||
*/
|
||||
export function disposeAllCategoryViewAutomats() {
|
||||
for (const automat of categoryViewInstances) {
|
||||
try {
|
||||
automat.dispose();
|
||||
} catch (e) {
|
||||
console.error('[categoryAutomat] dispose view failed', e);
|
||||
}
|
||||
}
|
||||
categoryViewInstances.clear();
|
||||
categoryIdToRouteName.clear();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
@@ -73,11 +160,6 @@ const actions = {
|
||||
languageId: parsed.languageId,
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
currentCategoryId: parsed.rootCategoryId,
|
||||
rootArticles: parsed.rootArticles,
|
||||
articles: parsed.rootArticles,
|
||||
articlesTotal: parsed.rootArticles.length,
|
||||
articlesStatus: 'succeeded',
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -89,62 +171,6 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load articles for a non-root category.
|
||||
* @param {number|string} categoryId
|
||||
*/
|
||||
async loadArticles(categoryId) {
|
||||
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
|
||||
try {
|
||||
const res = await categoryApi.getArticles(categoryId);
|
||||
const articles = Array.isArray(res.articles) ? res.articles : [];
|
||||
categoryAutomat.setState({
|
||||
articlesStatus: 'succeeded',
|
||||
articles,
|
||||
articlesTotal: articles.length,
|
||||
});
|
||||
} catch (err) {
|
||||
categoryAutomat.setState({
|
||||
articlesStatus: 'failed',
|
||||
articles: [],
|
||||
articlesTotal: 0,
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** Navigate to a category by id. */
|
||||
navigateTo(categoryId) {
|
||||
const { rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
const isRoot = String(categoryId) === String(rootCategoryId);
|
||||
categoryAutomat.setState({
|
||||
currentCategoryId: categoryId,
|
||||
error: null,
|
||||
articles: isRoot ? rootArticles : [],
|
||||
articlesTotal: isRoot ? rootArticles.length : 0,
|
||||
articlesStatus: isRoot ? 'succeeded' : 'idle',
|
||||
});
|
||||
},
|
||||
|
||||
/** Navigate up to the parent category. */
|
||||
navigateBack() {
|
||||
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
const current = categories?.[String(currentCategoryId)];
|
||||
const parentId = current?.parentId;
|
||||
if (parentId == null || parentId === 0) return;
|
||||
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
|
||||
const isRoot = String(parentId) === String(rootCategoryId);
|
||||
categoryAutomat.setState({
|
||||
currentCategoryId: parentId,
|
||||
error: null,
|
||||
articles: isRoot ? rootArticles : [],
|
||||
articlesTotal: isRoot ? rootArticles.length : 0,
|
||||
articlesStatus: isRoot ? 'succeeded' : 'idle',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggle rename/edit mode for the category grid. */
|
||||
setRenameMode(enabled) {
|
||||
categoryAutomat.setState({ renameMode: Boolean(enabled) });
|
||||
@@ -192,17 +218,17 @@ const actions = {
|
||||
async createArticle({ parentId, name }) {
|
||||
const res = await articleApi.create({ parentId, name });
|
||||
const created = res.article;
|
||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
||||
const patch = {};
|
||||
if (String(created.parentId) === String(currentCategoryId)) {
|
||||
patch.articles = [...articles, created];
|
||||
patch.articlesTotal = patch.articles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
if (created && parentId != null) {
|
||||
const routeName = categoryIdToRouteName.get(String(parentId));
|
||||
if (routeName) {
|
||||
const routeAutomat = Automat.get(routeName);
|
||||
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
|
||||
routeAutomat.setState({
|
||||
articles: [...routeAutomat.state.articles, created],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (String(created.parentId) === String(rootCategoryId)) {
|
||||
patch.rootArticles = [...rootArticles, created];
|
||||
}
|
||||
if (Object.keys(patch).length) categoryAutomat.setState(patch);
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -218,19 +244,17 @@ const actions = {
|
||||
const res = await articleApi.create({ parentId, name });
|
||||
if (res.article) created.push(res.article);
|
||||
}
|
||||
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
|
||||
const patch = {};
|
||||
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
|
||||
if (forCurrent.length > 0) {
|
||||
patch.articles = [...articles, ...forCurrent];
|
||||
patch.articlesTotal = patch.articles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
if (created.length > 0 && parentId != null) {
|
||||
const routeName = categoryIdToRouteName.get(String(parentId));
|
||||
if (routeName) {
|
||||
const routeAutomat = Automat.get(routeName);
|
||||
if (routeAutomat && Array.isArray(routeAutomat.state?.articles)) {
|
||||
routeAutomat.setState({
|
||||
articles: [...routeAutomat.state.articles, ...created],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId));
|
||||
if (forRoot.length > 0) {
|
||||
patch.rootArticles = [...rootArticles, ...forRoot];
|
||||
}
|
||||
if (Object.keys(patch).length) categoryAutomat.setState(patch);
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -247,14 +271,9 @@ const actions = {
|
||||
const res = await categoryApi.updateName(categoryId, trimmed);
|
||||
const updated = normalizeCategoryNode(res.category);
|
||||
const key = String(updated.id);
|
||||
const { categories, rootArticles } = categoryAutomat.state;
|
||||
const { categories } = categoryAutomat.state;
|
||||
categoryAutomat.setState({
|
||||
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
|
||||
rootArticles: rootArticles.map((row) =>
|
||||
row?.categoryId != null && String(row.categoryId) === key
|
||||
? { ...row, name: updated.name }
|
||||
: row,
|
||||
),
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
@@ -264,7 +283,7 @@ const actions = {
|
||||
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
|
||||
*/
|
||||
async reorderCategories({ parentId, categoryIds }) {
|
||||
const targetParentId = parentId != null ? parentId : categoryAutomat.state.currentCategoryId;
|
||||
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
|
||||
const res = await categoryApi.reorder(targetParentId, categoryIds);
|
||||
const ordered = res.categoryIds;
|
||||
const parentKey = String(targetParentId);
|
||||
@@ -304,18 +323,11 @@ const actions = {
|
||||
*/
|
||||
async deleteCategory(categoryId) {
|
||||
await categoryApi.delete(categoryId);
|
||||
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return;
|
||||
const next = { ...categories };
|
||||
delete next[String(categoryId)];
|
||||
const patch = { categories: next };
|
||||
if (String(currentCategoryId) === String(categoryId)) {
|
||||
patch.currentCategoryId = rootCategoryId;
|
||||
patch.articles = rootArticles;
|
||||
patch.articlesTotal = rootArticles.length;
|
||||
patch.articlesStatus = 'succeeded';
|
||||
}
|
||||
categoryAutomat.setState(patch);
|
||||
categoryAutomat.setState({ categories: next });
|
||||
return categoryId;
|
||||
},
|
||||
|
||||
@@ -372,55 +384,17 @@ const actions = {
|
||||
|
||||
// ─── Local state patches ───────────────────────────────────────────────────
|
||||
|
||||
/** Remove an article from both article lists (used after article deletion). */
|
||||
/** Remove an article from all live category view lists (used after article deletion). */
|
||||
removeArticleBySku(sku) {
|
||||
categoryAutomat.setState({
|
||||
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
|
||||
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
|
||||
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Patch article fields and category membership in local lists.
|
||||
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
|
||||
*/
|
||||
patchArticleInLists({ sku, categoryIds, ...fields }) {
|
||||
if (sku == null) return;
|
||||
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
|
||||
|
||||
const patchList = (list) =>
|
||||
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
|
||||
|
||||
let nextArticles = patchList(articles);
|
||||
let nextRoot = patchList(rootArticles);
|
||||
let nextArticlesTotal = nextArticles.length;
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
const newIds = categoryIds ?? [];
|
||||
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
|
||||
|
||||
if (rootCategoryId != null) {
|
||||
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
|
||||
const wasInRoot = nextRoot.some((r) => r.sku === sku);
|
||||
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
|
||||
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
|
||||
}
|
||||
|
||||
if (currentCategoryId != null) {
|
||||
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
|
||||
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
|
||||
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
|
||||
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
|
||||
nextArticlesTotal = nextArticles.length;
|
||||
if (!sku) return;
|
||||
for (const automat of categoryViewInstances) {
|
||||
const articles = automat.state?.articles;
|
||||
if (Array.isArray(articles) && articles.some((r) => r.sku === sku)) {
|
||||
automat.setState({
|
||||
articles: articles.filter((r) => r.sku !== sku),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
categoryAutomat.setState({
|
||||
articles: nextArticles,
|
||||
articlesTotal: nextArticlesTotal,
|
||||
rootArticles: nextRoot,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -445,6 +419,7 @@ const actions = {
|
||||
|
||||
/** Reset state to idle (logout / company switch). */
|
||||
reset() {
|
||||
disposeAllCategoryViewAutomats();
|
||||
categoryAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
@@ -464,6 +439,7 @@ companyAutomat.subscribe((company) => {
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
_lastCompanyId = id;
|
||||
disposeAllCategoryViewAutomats();
|
||||
void categoryAutomat.actions.loadTree();
|
||||
}
|
||||
if (!id && _lastCompanyId) {
|
||||
|
||||
@@ -51,4 +51,8 @@ export {
|
||||
categoryDisplayName,
|
||||
categorySlug,
|
||||
normalizeCategoryMap,
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
} from './categoryAutomat';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user