271 lines
9.0 KiB
JavaScript
271 lines
9.0 KiB
JavaScript
/**
|
|
* @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`: Ensures the derived index is available
|
|
* 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 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 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<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 with pagination.
|
|
*
|
|
* @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(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 = 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, loading: false });
|
|
return result;
|
|
} catch (error) {
|
|
const result = {
|
|
...emptyResult,
|
|
pageSize,
|
|
offset,
|
|
error,
|
|
};
|
|
articleSearchAutomat.setState({ result, loading: false });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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 and pagination state for the `/artikel` overview page.
|
|
*
|
|
* 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.
|
|
*
|
|
* @param {ArticleSearchState} state Automat state.
|
|
* @returns {ArticleSearchResult} Search outcome.
|
|
*/
|
|
export function selectSearchResult(state) {
|
|
return state.result;
|
|
}
|
|
|
|
export default articleSearchAutomat;
|