This commit is contained in:
seb
2026-07-31 17:51:57 +02:00
parent 797ea59bed
commit a6a9777f31
33 changed files with 1432 additions and 422 deletions

View File

@@ -1,5 +1,6 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { article, category } from '../api';
import { category, article } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice';
import { loadCurrentCompany } from './companySlice';
@@ -7,28 +8,35 @@ import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
requireObject(item, 'category');
requireDefined(item.id, 'category.id');
return {
...item,
id: item.id,
parentId: item.parentId ?? 0,
name: item.name?.trim() || null,
parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null,
};
}
/** Backend: { tree: { tree: [...], articles: [] } } */
export function parseCategoryTreeResponse(data, companyRootCategoryId = null) {
const payload = data?.tree ?? data;
const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? [];
const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes);
/** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
requireObject(data, 'category/tree');
const list = requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
requireDefined(rootCategoryId, 'rootCategoryId');
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null;
const rootArticles = Array.isArray(payload?.articles) ? payload.articles : [];
return { rootCategoryId, categories, rootArticles };
return {
rootCategoryId,
categories,
rootArticles: data.articles,
languageId: data.languageId,
};
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
@@ -38,33 +46,65 @@ export function categoryDisplayName(name, categoryId, rootCategoryId) {
return name?.trim() || '';
}
export function normalizeCategoryMap(data) {
return parseCategoryTreeResponse(data).categories;
export function categorySlug(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
const trimmed = name?.trim();
return trimmed || null;
}
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => String(item.parentId ?? 0) === parentKey)
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
if (!categories || currentCategoryId == null) return [];
const crumbs = [];
const seen = new Set();
let id = currentCategoryId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = categories[String(id)];
if (!node) break;
crumbs.unshift({
id: node.id,
name: categoryDisplayName(node.name, node.id, rootCategoryId),
slug: categorySlug(node.name, node.id, rootCategoryId),
});
const parentId = node.parentId;
if (parentId == null || parentId === 0) break;
if (String(parentId) === String(id)) break;
id = parentId;
}
return crumbs;
}
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
async (companyId, { rejectWithValue, getState }) => {
async (_, { rejectWithValue, getState }) => {
try {
const data = await category.getTree({ companyId });
const fallbackRootId = getState().company.current?.rootCategoryId ?? null;
const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse(
data,
fallbackRootId,
);
const companyId = getState().company.current?.id;
const companyRootId = getState().company.current?.rootCategoryId;
requireDefined(companyRootId, 'company.rootCategoryId');
const data = await category.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
return {
companyId,
rootCategoryId,
categories,
rootArticles,
...parsed,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
@@ -74,14 +114,51 @@ export const loadCategoryTree = createAsyncThunk(
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => {
async (categoryId, { rejectWithValue }) => {
try {
const data = await article.byCategory({ categoryId, offset, maxCount });
return {
categoryId,
recordset: data.recordset ?? [],
length: data.length ?? data.recordset?.length ?? 0,
};
const data = await category.getArticles(categoryId);
requireObject(data, `category/${categoryId}/articles`);
const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`);
return { categoryId, articles };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const createCategory = createAsyncThunk(
'category/create',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const createArticle = createAsyncThunk(
'category/createArticle',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
return data.article;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk(
'category/delete',
async (categoryId, { rejectWithValue }) => {
try {
await category.delete(categoryId);
return categoryId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
@@ -90,6 +167,7 @@ export const loadCategoryArticles = createAsyncThunk(
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
@@ -107,10 +185,16 @@ const categorySlice = createSlice({
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
state.error = null;
if (String(action.payload) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
@@ -143,10 +227,11 @@ const categorySlice = createSlice({
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
state.languageId = action.payload.languageId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles ?? [];
state.rootArticles = action.payload.rootArticles;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
@@ -161,8 +246,8 @@ const categorySlice = createSlice({
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
state.articles = action.payload.recordset;
state.articlesTotal = action.payload.length;
state.articles = action.payload.articles;
state.articlesTotal = action.payload.articles.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
@@ -170,6 +255,32 @@ const categorySlice = createSlice({
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
.addCase(createCategory.fulfilled, (state, action) => {
if (!state.categories) state.categories = {};
state.categories[String(action.payload.id)] = action.payload;
})
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;
if (String(created.parentId) === String(state.currentCategoryId)) {
state.articles = [...state.articles, created];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(state.rootCategoryId)) {
state.rootArticles = [...state.rootArticles, created];
}
})
.addCase(deleteCategory.fulfilled, (state, action) => {
if (!state.categories) return;
delete state.categories[String(action.payload)];
if (String(state.currentCategoryId) === String(action.payload)) {
state.currentCategoryId = state.rootCategoryId;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
}
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
@@ -206,6 +317,13 @@ export const selectCurrentCategory = (state) => {
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
export const selectCategoryBreadcrumbs = (state) =>
getCategoryBreadcrumbs(
state.category.categories,
state.category.currentCategoryId,
state.category.rootCategoryId,
);
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;