u
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user