/** * @file articleSearchAutomat.js * @description In-memory article search and pagination state for the `/artikel` * overview and search pages, querying against the derived `articleIndexAutomat`. * * Upstream Triggers: * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`. * - Reads data from `articleIndexAutomat`: Derives and slices matching records * synchronously, updating continuously as category views load during launch. * - `categoryAutomat`: Provides category hierarchy metadata for faceted counts. */ import { Automat } from 'react-automat'; import articleIndexAutomat from './articleIndexAutomat'; import categoryAutomat from './categoryAutomat'; import { parseFilter } from '../components/article/searchFormat'; /** * @typedef {Object} ArticleSearchResult * @property {Record} categoryFacets Facet counts per category id. * @property {number} totalCount Number of matching records. * @property {Array} recordset Sliced matching article index records for the current page. * @property {number} offset Current 0-based offset. * @property {number|null} pageSize Current page size. * @property {number} pageCount Total number of pages. * @property {number} currentPage Current 1-based page number. * @property {Error|null} error Search failure, or `null`. */ /** * @typedef {Object} ArticleSearchState * @property {ArticleSearchResult} result Latest search outcome. * @property {string} target Active search term. * @property {string} filter Active category filter string. * @property {number} offset Current 0-based offset. * @property {number|null} pageSize Current page size. * @property {boolean} loading Whether a search or index load is in flight. */ /** @type {ArticleSearchResult} */ const emptyResult = { categoryFacets: {}, totalCount: 0, recordset: [], offset: 0, pageSize: null, pageCount: 1, currentPage: 1, error: null, }; /** @type {ArticleSearchState} */ const initialState = { result: emptyResult, target: '', filter: '', offset: 0, pageSize: null, loading: false, }; let articleSearchAutomat; /** * Matches one index record against a search query (checks tokens in name, sku, brand, eans). * * @param {object} row Article index record. * @param {string} query Lowercased search term. * @returns {boolean} Whether the record matches. */ function matches(row, query) { if (!query) return true; const tokens = query.split(/\s+/).filter(Boolean); if (tokens.length === 0) return true; const name = String(row.name ?? '').toLowerCase(); const sku = String(row.sku ?? '').toLowerCase(); const brand = String(row.brand?.name || row.brandName || '').toLowerCase(); const eans = (row.eans ?? []).map((e) => String(e).toLowerCase()); return tokens.every( (token) => name.includes(token) || sku.includes(token) || brand.includes(token) || eans.some((ean) => ean.includes(token)), ); } /** * Checks whether an article belongs to at least one of the selected categories. * * @param {object} row Article index record. * @param {Array} selectedCategories Array of category ID strings. * @returns {boolean} Whether the article matches. */ function matchesCategory(row, selectedCategories) { if (!selectedCategories || selectedCategories.length === 0) return true; if (!row.categories) return false; const rowCats = String(row.categories).split(',').map((s) => s.trim()); return selectedCategories.some((catId) => rowCats.includes(String(catId))); } /** * Computes facet counts for each category represented in matched articles. * * @param {Array} matchedArticles Articles matching text query. * @returns {Record} Category facets. */ function buildCategoryFacets(matchedArticles) { const facets = {}; const categoriesMap = categoryAutomat.state?.categories ?? {}; for (const article of matchedArticles) { if (!article.categories) continue; const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean); for (const id of ids) { if (!facets[id]) { const catNode = categoriesMap[id]; facets[id] = { count: 0, name: catNode?.name ?? `Kategorie ${id}`, parent: catNode?.parentId ?? null, }; } facets[id].count += 1; } } return facets; } const actions = { /** * Executes in-memory search and derivation synchronously. * * @param {object} [params] Search parameters to merge. * @returns {ArticleSearchResult} Search outcome. */ runSearch(params = {}) { const currentState = articleSearchAutomat.state; const target = params.target !== undefined ? String(params.target ?? '') : currentState.target; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const articles = articleIndexAutomat.state.items ?? []; const query = target.trim().toLowerCase(); // Filter by text query across all tokens const textMatches = query ? articles.filter((row) => matches(row, query)) : articles; // Build facets for all text matches (before category filtering) const categoryFacets = buildCategoryFacets(textMatches); // Apply category filter if set const selectedCategories = parseFilter(filter); const filteredArticles = selectedCategories.length > 0 ? textMatches.filter((row) => matchesCategory(row, selectedCategories)) : textMatches; const totalCount = filteredArticles.length; const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null; const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1; const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1; const recordset = safePageSize ? filteredArticles.slice(offset, offset + safePageSize) : filteredArticles; const result = { categoryFacets, totalCount, recordset, offset, pageSize: safePageSize, pageCount, currentPage, error: null, }; articleSearchAutomat.setState({ result, target, filter, offset, pageSize: safePageSize, loading: articleIndexAutomat.state.status === 'loading', }); return result; }, /** * Runs a client-side search over the article index with pagination. * * Does not block on network calls: evaluates immediately against whatever * articles are currently loaded, and ensures index derivation is running in the background. * * @param {string|object} [targetOrParams] Search term or options object. * @param {object} [options] Secondary options when first argument is target string. * @returns {Promise} Search outcome. */ async search(targetOrParams, options = {}) { const params = typeof targetOrParams === 'object' && targetOrParams !== null ? targetOrParams : targetOrParams !== undefined ? { target: targetOrParams, ...options } : options; // Kick off index loading in the background if not ready yet if (articleIndexAutomat.state.status === 'idle') { void articleIndexAutomat.actions.ensureLoaded().catch(() => {}); } return actions.runSearch(params); }, /** * Updates the page size and re-derives the result set. * * @param {number} pageSize Number of articles per page. * @returns {ArticleSearchResult} */ setPageSize(pageSize) { return actions.runSearch({ pageSize }); }, /** * Sets page number (1-based) and re-derives the result set. * * @param {number} page 1-based page number. * @returns {ArticleSearchResult} */ setPage(page) { const safeSize = articleSearchAutomat.state.pageSize ?? 1; const offset = Math.max(0, (page - 1) * safeSize); return actions.runSearch({ offset }); }, /** * Sets 0-based offset and re-derives the result set. * * @param {number} offset 0-based record offset. * @returns {ArticleSearchResult} */ setOffset(offset) { return actions.runSearch({ offset }); }, /** Reset the search state. */ reset() { articleSearchAutomat.setState(initialState); }, }; /** * Article search and pagination state for the `/artikel` overview page. * * Components subscribe to `result` to receive paginated records, facets, * page counts, and total counts. * * @type {Automat} */ articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); // Continuously re-derive and update subscribers as category views load their article lists. articleIndexAutomat.subscribe(() => { actions.runSearch(); }); // Update facet names whenever the category tree is loaded/updated. categoryAutomat.subscribe((catState) => { if (catState.treeStatus === 'succeeded') { actions.runSearch(); } }); /** * Selects the latest search result. * * @param {ArticleSearchState} state Automat state. * @returns {ArticleSearchResult} Search outcome. */ export function selectSearchResult(state) { return state.result; } export default articleSearchAutomat;