This commit is contained in:
seb
2026-10-05 09:17:04 +02:00
parent 8010436157
commit 5b4cf1683a
7 changed files with 493 additions and 221 deletions

View File

@@ -5,8 +5,8 @@
*
* 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.
* - 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.
*/
@@ -34,7 +34,7 @@ import { parseFilter } from '../components/article/searchFormat';
* @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.
* @property {boolean} loading Whether a search or index load is in flight.
*/
/** @type {ArticleSearchResult} */
@@ -130,108 +130,121 @@ function buildCategoryFacets(matchedArticles) {
const actions = {
/**
* Runs a client-side search over the article index with pagination.
* Executes in-memory search and derivation synchronously.
*
* @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.
* @param {object} [params] Search parameters to merge.
* @returns {ArticleSearchResult} Search outcome.
*/
async search(targetOrParams, options = {}) {
runSearch(params = {}) {
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();
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;
// 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);
// 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;
// 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 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,
};
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;
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<ArticleSearchResult>} 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 {Promise<ArticleSearchResult>}
* @returns {ArticleSearchResult}
*/
setPageSize(pageSize) {
const { target, filter, offset } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
return actions.runSearch({ pageSize });
},
/**
* Sets page number (1-based) and re-derives the result set.
*
* @param {number} page 1-based page number.
* @returns {Promise<ArticleSearchResult>}
* @returns {ArticleSearchResult}
*/
setPage(page) {
const { target, filter, pageSize } = articleSearchAutomat.state;
const safeSize = pageSize ?? 1;
const safeSize = articleSearchAutomat.state.pageSize ?? 1;
const offset = Math.max(0, (page - 1) * safeSize);
return actions.search({ target, filter, offset, pageSize });
return actions.runSearch({ offset });
},
/**
* Sets 0-based offset and re-derives the result set.
*
* @param {number} offset 0-based record offset.
* @returns {Promise<ArticleSearchResult>}
* @returns {ArticleSearchResult}
*/
setOffset(offset) {
const { target, filter, pageSize } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
return actions.runSearch({ offset });
},
/** Reset the search state. */
@@ -250,10 +263,15 @@ const actions = {
*/
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
// Continuously re-derive and update subscribers as category views load their article lists.
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(() => {});
actions.runSearch();
});
// Update facet names whenever the category tree is loaded/updated.
categoryAutomat.subscribe((catState) => {
if (catState.treeStatus === 'succeeded') {
actions.runSearch();
}
});

View File

@@ -10,7 +10,15 @@ export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export {
setLastDeliveryPath,
setLastArticleSku,
setLastSearchTerm,
setLastArticleSearchPath,
setLastCategorySlug,
setLastCategoryPath,
clearNavigation,
} from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';

View File

@@ -1,11 +1,13 @@
/**
* @file navigationAutomat.js
* @description Stores the user's most recently visited delivery document path
* and article SKU to preserve navigation context across primary section switches.
* @description Stores the user's most recently visited delivery document path,
* article SKU, article search term/path, and category slug/path to preserve
* navigation context across primary and secondary section switches.
*
* Upstream Triggers:
* - UI / Routes: Set by article and delivery views/routes via `setLastDeliveryPath(path)`,
* `setLastArticleSku(sku)`, and `clearNavigation()`.
* - UI / Routes: Set by article search, category navigation, and delivery views/routes
* via `setLastDeliveryPath(path)`, `setLastArticleSku(sku)`, `setLastSearchTerm(term, path)`,
* `setLastCategorySlug(slug)`, and `clearNavigation()`.
*/
import { Automat } from 'react-automat';
@@ -14,17 +16,25 @@ import { Automat } from 'react-automat';
* @typedef {Object} NavigationState
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
* @property {string|null} lastArticleSku Last article SKU visited by the user.
* @property {string|null} lastSearchTerm Last article search query term.
* @property {string|null} lastArticleSearchPath Full last search path (e.g. `/artikel?target=apfel`).
* @property {string|null} lastCategorySlug Last visited category slug (e.g. `obst-gemuese/obst`).
* @property {string|null} lastCategoryPath Full last category path (e.g. `/kategorien/obst-gemuese/obst`).
*/
/** @type {NavigationState} */
const initialState = {
lastDeliveryPath: null,
lastArticleSku: null,
lastSearchTerm: null,
lastArticleSearchPath: null,
lastCategorySlug: null,
lastCategoryPath: null,
};
/**
* Stores the user's most recently visited delivery and article so main-menu
* navigation can restore the previous context.
* Stores the user's most recently visited delivery, article, search term, and category
* so main-menu and sub-menu navigation can restore the previous context.
*
* @type {Automat<NavigationState>}
*/
@@ -33,9 +43,9 @@ const navigationAutomat = new Automat(
{
/**
* Set the last visited delivery path.
*
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void}
*
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void}
*/
setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path });
@@ -43,24 +53,81 @@ const navigationAutomat = new Automat(
/**
* Set the last visited article SKU.
*
*
* @param {string|null} sku
* @returns {void}
* @returns {void}
*/
setLastArticleSku(sku) {
navigationAutomat.setState({ lastArticleSku: sku });
},
/**
* Set the last active article search term and optional search URL.
*
* @param {string|null} term Search input term.
* @param {string|null} [path] Full search URL.
* @returns {void}
*/
setLastSearchTerm(term, path = null) {
const trimmed = term != null ? String(term) : '';
const searchPath = path ?? (trimmed ? `/artikel?target=${encodeURIComponent(trimmed)}` : '/artikel');
navigationAutomat.setState({
lastSearchTerm: trimmed || null,
lastArticleSearchPath: searchPath,
});
},
/**
* Set the full last article search URL path.
*
* @param {string|null} path Full search URL path (e.g. `/artikel?target=apfel`).
* @param {string|null} [term] Search term.
* @returns {void}
*/
setLastArticleSearchPath(path, term = null) {
navigationAutomat.setState({
lastArticleSearchPath: path,
...(term !== null ? { lastSearchTerm: term } : {}),
});
},
/**
* Set the last visited category slug.
*
* @param {string|null} slug Category slug (e.g. `obst-gemuese/obst` or empty string for root).
* @returns {void}
*/
setLastCategorySlug(slug) {
const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : '';
const path = clean ? `/kategorien/${clean}` : '/kategorien';
navigationAutomat.setState({
lastCategorySlug: clean || null,
lastCategoryPath: path,
});
},
/**
* Set the full last category path.
*
* @param {string|null} path Full category path (e.g. `/kategorien/obst-gemuese/obst`).
* @returns {void}
*/
setLastCategoryPath(path) {
const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien';
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '');
navigationAutomat.setState({
lastCategorySlug: slug || null,
lastCategoryPath: cleanPath,
});
},
/**
* Clear all navigation history.
*
* @returns {void}
*
* @returns {void}
*/
clearNavigation() {
navigationAutomat.setState({
lastDeliveryPath: null,
lastArticleSku: null,
});
navigationAutomat.setState(initialState);
},
},
{
@@ -70,4 +137,12 @@ const navigationAutomat = new Automat(
);
export default navigationAutomat;
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions;
export const {
setLastDeliveryPath,
setLastArticleSku,
setLastSearchTerm,
setLastArticleSearchPath,
setLastCategorySlug,
setLastCategoryPath,
clearNavigation,
} = navigationAutomat.actions;