This commit is contained in:
seb
2026-10-05 08:31:13 +02:00
parent 4d653304cd
commit 8010436157
5 changed files with 295 additions and 151 deletions

View File

@@ -1,23 +1,29 @@
/**
* @file articleSearchAutomat.js
* @description In-memory article search state for the `/artikel` overview and
* search pages, querying against the derived `articleIndexAutomat`.
* @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: Components call `actions.search(query)` or `actions.reset()`.
* - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`.
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
* before filtering. The index is built from the loaded category views, so a
* search never issues a request of its own.
* before filtering and slicing.
* - `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<string,object>} categoryFacets Facet counts per category id.
* @property {number} totalCount Number of matching records.
* @property {Array<object>} recordset Matching article index records.
* @property {Array<object>} 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`.
*/
@@ -25,6 +31,9 @@ import articleIndexAutomat from './articleIndexAutomat';
* @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 is in flight.
*/
@@ -33,6 +42,10 @@ const emptyResult = {
categoryFacets: {},
totalCount: 0,
recordset: [],
offset: 0,
pageSize: null,
pageCount: 1,
currentPage: 1,
error: null,
};
@@ -40,73 +53,210 @@ const emptyResult = {
const initialState = {
result: emptyResult,
target: '',
filter: '',
offset: 0,
pageSize: null,
loading: false,
};
let articleSearchAutomat;
/**
* Matches one index record against a lowercased search term.
* 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 (String(row.name ?? '').toLowerCase().includes(query)) return true;
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
return (row.eans ?? []).some((ean) => String(ean).includes(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<string>} 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<object>} matchedArticles Articles matching text query.
* @returns {Record<string, {count: number, name: string, parent: any}>} 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 = {
/**
* Runs a client-side search over the article index.
* Runs a client-side search over the article index with pagination.
*
* The index is already derived from the cached category views, so this does
* not hit the network once the tree has loaded.
*
* @param {string} target Search term; an empty string returns everything.
* @param {string|object} targetOrParams Search term or options object.
* @param {object} [options] Secondary options when first argument is target string.
* @returns {Promise<ArticleSearchResult>} Search outcome.
*/
async search(target) {
articleSearchAutomat.setState({ target, loading: true });
async search(targetOrParams, options = {}) {
const currentState = articleSearchAutomat.state;
const params = typeof targetOrParams === 'object' && targetOrParams !== null
? targetOrParams
: { target: targetOrParams, ...options };
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;
articleSearchAutomat.setState({ target, filter, offset, pageSize, loading: true });
try {
const articles = await articleIndexAutomat.actions.ensureLoaded();
const query = String(target ?? '').trim().toLowerCase();
const recordset = query
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.length,
categoryFacets,
totalCount,
recordset,
offset,
pageSize: safePageSize,
pageCount,
currentPage,
error: null,
};
articleSearchAutomat.setState({ result, loading: false });
return result;
} catch (error) {
const result = { ...emptyResult, error };
const result = {
...emptyResult,
pageSize,
offset,
error,
};
articleSearchAutomat.setState({ result, loading: false });
throw error;
}
},
/** Reset the result set. */
/**
* Updates the page size and re-derives the result set.
*
* @param {number} pageSize Number of articles per page.
* @returns {Promise<ArticleSearchResult>}
*/
setPageSize(pageSize) {
const { target, filter, offset } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
},
/**
* Sets page number (1-based) and re-derives the result set.
*
* @param {number} page 1-based page number.
* @returns {Promise<ArticleSearchResult>}
*/
setPage(page) {
const { target, filter, pageSize } = articleSearchAutomat.state;
const safeSize = pageSize ?? 1;
const offset = Math.max(0, (page - 1) * safeSize);
return actions.search({ target, filter, offset, pageSize });
},
/**
* Sets 0-based offset and re-derives the result set.
*
* @param {number} offset 0-based record offset.
* @returns {Promise<ArticleSearchResult>}
*/
setOffset(offset) {
const { target, filter, pageSize } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
},
/** Reset the search state. */
reset() {
articleSearchAutomat.setState(initialState);
},
};
/**
* Article search state for the `/artikel` overview page.
* Article search and pagination state for the `/artikel` overview page.
*
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
* instead of receiving a callback.
* Components subscribe to `result` to receive paginated records, facets,
* page counts, and total counts.
*
* @type {Automat<ArticleSearchState>}
*/
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
articleIndexAutomat.subscribe(() => {
const { target, filter, offset, pageSize, loading } = articleSearchAutomat.state;
if (pageSize != null && !loading && articleIndexAutomat.state.status === 'ready') {
void actions.search({ target, filter, offset, pageSize }).catch(() => {});
}
});
/**
* Selects the latest search result.
*