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: * Upstream Triggers:
* - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`. * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`.
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available * - Reads data from `articleIndexAutomat`: Derives and slices matching records
* before filtering and slicing. * synchronously, updating continuously as category views load during launch.
* - `categoryAutomat`: Provides category hierarchy metadata for faceted counts. * - `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 {string} filter Active category filter string.
* @property {number} offset Current 0-based offset. * @property {number} offset Current 0-based offset.
* @property {number|null} pageSize Current page size. * @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} */ /** @type {ArticleSearchResult} */
@@ -130,108 +130,121 @@ function buildCategoryFacets(matchedArticles) {
const actions = { 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} [params] Search parameters to merge.
* @param {object} [options] Secondary options when first argument is target string. * @returns {ArticleSearchResult} Search outcome.
* @returns {Promise<ArticleSearchResult>} Search outcome.
*/ */
async search(targetOrParams, options = {}) { runSearch(params = {}) {
const currentState = articleSearchAutomat.state; 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 target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize; const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
articleSearchAutomat.setState({ target, filter, offset, pageSize, loading: true }); const articles = articleIndexAutomat.state.items ?? [];
try { const query = target.trim().toLowerCase();
const articles = await articleIndexAutomat.actions.ensureLoaded();
const query = target.trim().toLowerCase();
// Filter by text query across all tokens // Filter by text query across all tokens
const textMatches = query const textMatches = query
? articles.filter((row) => matches(row, query)) ? articles.filter((row) => matches(row, query))
: articles; : articles;
// Build facets for all text matches (before category filtering) // Build facets for all text matches (before category filtering)
const categoryFacets = buildCategoryFacets(textMatches); const categoryFacets = buildCategoryFacets(textMatches);
// Apply category filter if set // Apply category filter if set
const selectedCategories = parseFilter(filter); const selectedCategories = parseFilter(filter);
const filteredArticles = selectedCategories.length > 0 const filteredArticles = selectedCategories.length > 0
? textMatches.filter((row) => matchesCategory(row, selectedCategories)) ? textMatches.filter((row) => matchesCategory(row, selectedCategories))
: textMatches; : textMatches;
const totalCount = filteredArticles.length; const totalCount = filteredArticles.length;
const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null; const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null;
const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1; const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1;
const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1; const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1;
const recordset = safePageSize const recordset = safePageSize
? filteredArticles.slice(offset, offset + safePageSize) ? filteredArticles.slice(offset, offset + safePageSize)
: filteredArticles; : filteredArticles;
const result = { const result = {
categoryFacets, categoryFacets,
totalCount, totalCount,
recordset, recordset,
offset, offset,
pageSize: safePageSize, pageSize: safePageSize,
pageCount, pageCount,
currentPage, currentPage,
error: null, error: null,
}; };
articleSearchAutomat.setState({ result, loading: false }); articleSearchAutomat.setState({
return result; result,
} catch (error) { target,
const result = { filter,
...emptyResult, offset,
pageSize, pageSize: safePageSize,
offset, loading: articleIndexAutomat.state.status === 'loading',
error, });
};
articleSearchAutomat.setState({ result, loading: false }); return result;
throw error; },
/**
* 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. * Updates the page size and re-derives the result set.
* *
* @param {number} pageSize Number of articles per page. * @param {number} pageSize Number of articles per page.
* @returns {Promise<ArticleSearchResult>} * @returns {ArticleSearchResult}
*/ */
setPageSize(pageSize) { setPageSize(pageSize) {
const { target, filter, offset } = articleSearchAutomat.state; return actions.runSearch({ pageSize });
return actions.search({ target, filter, offset, pageSize });
}, },
/** /**
* Sets page number (1-based) and re-derives the result set. * Sets page number (1-based) and re-derives the result set.
* *
* @param {number} page 1-based page number. * @param {number} page 1-based page number.
* @returns {Promise<ArticleSearchResult>} * @returns {ArticleSearchResult}
*/ */
setPage(page) { setPage(page) {
const { target, filter, pageSize } = articleSearchAutomat.state; const safeSize = articleSearchAutomat.state.pageSize ?? 1;
const safeSize = pageSize ?? 1;
const offset = Math.max(0, (page - 1) * safeSize); 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. * Sets 0-based offset and re-derives the result set.
* *
* @param {number} offset 0-based record offset. * @param {number} offset 0-based record offset.
* @returns {Promise<ArticleSearchResult>} * @returns {ArticleSearchResult}
*/ */
setOffset(offset) { setOffset(offset) {
const { target, filter, pageSize } = articleSearchAutomat.state; return actions.runSearch({ offset });
return actions.search({ target, filter, offset, pageSize });
}, },
/** Reset the search state. */ /** Reset the search state. */
@@ -250,10 +263,15 @@ const actions = {
*/ */
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
// Continuously re-derive and update subscribers as category views load their article lists.
articleIndexAutomat.subscribe(() => { articleIndexAutomat.subscribe(() => {
const { target, filter, offset, pageSize, loading } = articleSearchAutomat.state; actions.runSearch();
if (pageSize != null && !loading && articleIndexAutomat.state.status === 'ready') { });
void actions.search({ target, filter, offset, pageSize }).catch(() => {});
// 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 * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat'; 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 { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat'; export { setViewerPosition } from './userSettingsAutomat';

View File

@@ -1,11 +1,13 @@
/** /**
* @file navigationAutomat.js * @file navigationAutomat.js
* @description Stores the user's most recently visited delivery document path * @description Stores the user's most recently visited delivery document path,
* and article SKU to preserve navigation context across primary section switches. * article SKU, article search term/path, and category slug/path to preserve
* navigation context across primary and secondary section switches.
* *
* Upstream Triggers: * Upstream Triggers:
* - UI / Routes: Set by article and delivery views/routes via `setLastDeliveryPath(path)`, * - UI / Routes: Set by article search, category navigation, and delivery views/routes
* `setLastArticleSku(sku)`, and `clearNavigation()`. * via `setLastDeliveryPath(path)`, `setLastArticleSku(sku)`, `setLastSearchTerm(term, path)`,
* `setLastCategorySlug(slug)`, and `clearNavigation()`.
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
@@ -14,17 +16,25 @@ import { Automat } from 'react-automat';
* @typedef {Object} NavigationState * @typedef {Object} NavigationState
* @property {string|null} lastDeliveryPath Last delivery path visited by the user. * @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} 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} */ /** @type {NavigationState} */
const initialState = { const initialState = {
lastDeliveryPath: null, lastDeliveryPath: null,
lastArticleSku: null, lastArticleSku: null,
lastSearchTerm: null,
lastArticleSearchPath: null,
lastCategorySlug: null,
lastCategoryPath: null,
}; };
/** /**
* Stores the user's most recently visited delivery and article so main-menu * Stores the user's most recently visited delivery, article, search term, and category
* navigation can restore the previous context. * so main-menu and sub-menu navigation can restore the previous context.
* *
* @type {Automat<NavigationState>} * @type {Automat<NavigationState>}
*/ */
@@ -33,9 +43,9 @@ const navigationAutomat = new Automat(
{ {
/** /**
* Set the last visited delivery path. * Set the last visited delivery path.
* *
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`. * @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void} * @returns {void}
*/ */
setLastDeliveryPath(path) { setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path }); navigationAutomat.setState({ lastDeliveryPath: path });
@@ -43,24 +53,81 @@ const navigationAutomat = new Automat(
/** /**
* Set the last visited article SKU. * Set the last visited article SKU.
* *
* @param {string|null} sku * @param {string|null} sku
* @returns {void} * @returns {void}
*/ */
setLastArticleSku(sku) { setLastArticleSku(sku) {
navigationAutomat.setState({ lastArticleSku: 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. * Clear all navigation history.
* *
* @returns {void} * @returns {void}
*/ */
clearNavigation() { clearNavigation() {
navigationAutomat.setState({ navigationAutomat.setState(initialState);
lastDeliveryPath: null,
lastArticleSku: null,
});
}, },
}, },
{ {
@@ -70,4 +137,12 @@ const navigationAutomat = new Automat(
); );
export default navigationAutomat; export default navigationAutomat;
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions; export const {
setLastDeliveryPath,
setLastArticleSku,
setLastSearchTerm,
setLastArticleSearchPath,
setLastCategorySlug,
setLastCategoryPath,
clearNavigation,
} = navigationAutomat.actions;

View File

@@ -10,7 +10,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import HelpBadge from './HelpBadge'; import HelpBadge from './HelpBadge';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath } from './article/artikelQuery'; import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
import { defaultCategoryPath } from './category/categoryRoute'; import { defaultCategoryPath } from './category/categoryRoute';
import { navigationAutomat } from '../automats'; import { navigationAutomat } from '../automats';
import { import {
@@ -31,6 +31,8 @@ class AppSidebar extends PureComponent {
this.state = { this.state = {
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath, lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
lastArticleSku: navigationAutomat.state.lastArticleSku, lastArticleSku: navigationAutomat.state.lastArticleSku,
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
}; };
} }
@@ -38,6 +40,8 @@ class AppSidebar extends PureComponent {
this.unsub = navigationAutomat.subscribe(this, (state) => ({ this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastDeliveryPath: state.lastDeliveryPath, lastDeliveryPath: state.lastDeliveryPath,
lastArticleSku: state.lastArticleSku, lastArticleSku: state.lastArticleSku,
lastArticleSearchPath: state.lastArticleSearchPath,
lastSearchTerm: state.lastSearchTerm,
})); }));
} }
@@ -47,7 +51,12 @@ class AppSidebar extends PureComponent {
render() { render() {
const { activeNav, navigate } = this.props; const { activeNav, navigate } = this.props;
const { lastDeliveryPath, lastArticleSku } = this.state; const {
lastDeliveryPath,
lastArticleSku,
lastArticleSearchPath,
lastSearchTerm,
} = this.state;
return ( return (
<Box <Box
@@ -79,6 +88,10 @@ class AppSidebar extends PureComponent {
if (path === '/artikel') { if (path === '/artikel') {
if (lastArticleSku) { if (lastArticleSku) {
navigate(`/artikel/${lastArticleSku}`); navigate(`/artikel/${lastArticleSku}`);
} else if (lastArticleSearchPath) {
navigate(lastArticleSearchPath);
} else if (lastSearchTerm) {
navigate(buildSearchUrl(lastSearchTerm));
} else { } else {
navigate(artikelQueryPath()); navigate(artikelQueryPath());
} }
@@ -133,8 +146,37 @@ class AppSidebar extends PureComponent {
} }
class AppSubNav extends PureComponent { class AppSubNav extends PureComponent {
constructor(props) {
super(props);
this.state = {
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
lastCategoryPath: navigationAutomat.state.lastCategoryPath,
lastCategorySlug: navigationAutomat.state.lastCategorySlug,
};
}
componentDidMount() {
this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastArticleSearchPath: state.lastArticleSearchPath,
lastSearchTerm: state.lastSearchTerm,
lastCategoryPath: state.lastCategoryPath,
lastCategorySlug: state.lastCategorySlug,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() { render() {
const { activeNav, activeSub, navigate } = this.props; const { activeNav, activeSub, navigate } = this.props;
const {
lastArticleSearchPath,
lastSearchTerm,
lastCategoryPath,
lastCategorySlug,
} = this.state;
const subNav = activeNav ? SUB_NAV[activeNav] : null; const subNav = activeNav ? SUB_NAV[activeNav] : null;
if (!subNav?.length) { if (!subNav?.length) {
@@ -178,11 +220,19 @@ class AppSubNav extends PureComponent {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
if (!segment && activeNav === '/artikel') { if (!segment && activeNav === '/artikel') {
navigate(artikelQueryPath()); const target =
lastArticleSearchPath ||
(lastSearchTerm ? buildSearchUrl(lastSearchTerm) : null) ||
artikelQueryPath();
navigate(target);
return; return;
} }
if (segment === 'kategorien') { if (segment === 'kategorien') {
navigate(defaultCategoryPath()); const target =
lastCategoryPath ||
(lastCategorySlug ? `/kategorien/${lastCategorySlug}` : null) ||
defaultCategoryPath();
navigate(target);
return; return;
} }
navigate(segment ? `${activeNav}/${segment}` : activeNav); navigate(segment ? `${activeNav}/${segment}` : activeNav);

View File

@@ -8,8 +8,8 @@ export default function withSearchParams(Wrapped) {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const query = queryFromParams(searchParams); const currentTarget = searchParams.get('target');
if (query.target.trim().length >= 3) return; if (currentTarget != null && currentTarget.trim().length > 0) return;
const stored = readArtikelQuery(); const stored = readArtikelQuery();
if (!stored?.target || stored.target.trim().length < 3) return; if (!stored?.target || stored.target.trim().length < 3) return;
@@ -18,7 +18,7 @@ export default function withSearchParams(Wrapped) {
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0), buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
{ replace: true }, { replace: true },
); );
}, [searchParams, navigate]); }, []);
return ( return (
<Wrapped <Wrapped

View File

@@ -1,6 +1,9 @@
import { Component, createRef } from 'react'; import { Component, createRef } from 'react';
import FillSpace from 'dashgui/FillSpace'; import FillSpace from 'dashgui/FillSpace';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
@@ -10,6 +13,10 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat'; import articleSearchAutomat from '../automats/articleSearchAutomat';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import { formatPrice } from '../utils/price'; import { formatPrice } from '../utils/price';
@@ -30,7 +37,7 @@ import {
import { preserveSearchFocus } from '../components/article/searchFocus'; import { preserveSearchFocus } from '../components/article/searchFocus';
import { artikelDetailPath } from '../components/article/articleRoute'; import { artikelDetailPath } from '../components/article/articleRoute';
import withSearchParams from '../components/article/withSearchParams'; import withSearchParams from '../components/article/withSearchParams';
import { setLastArticleSku } from '../automats'; import { setLastArticleSku, setLastSearchTerm, navigationAutomat } from '../automats';
import { BrandLabel } from '../components/brand'; import { BrandLabel } from '../components/brand';
const resultRowSx = { const resultRowSx = {
@@ -80,15 +87,19 @@ function ResultRowMeasureSample() {
function initialSearchState(searchParams) { function initialSearchState(searchParams) {
const query = queryFromParams(searchParams); const query = queryFromParams(searchParams);
const stored = readArtikelQuery(); const stored = readArtikelQuery();
const navTerm = navigationAutomat.state.lastSearchTerm;
const input = const input =
query.target.trim().length >= 3 query.target.trim().length > 0
? query.target ? query.target
: (stored?.target?.trim().length >= 3 ? stored.target : query.target); : (navTerm?.trim().length > 0
? navTerm
: (stored?.target?.trim().length >= 3 ? stored.target : ''));
const automatResult = articleSearchAutomat.state.result; const automatResult = articleSearchAutomat.state.result;
return { return {
input, input,
categoryFilterOpen: false,
error: automatResult.error, error: automatResult.error,
categoryFacets: automatResult.categoryFacets, categoryFacets: automatResult.categoryFacets,
totalCount: automatResult.totalCount, totalCount: automatResult.totalCount,
@@ -103,13 +114,22 @@ function initialSearchState(searchParams) {
class ArtikelSearchPage extends Component { class ArtikelSearchPage extends Component {
state = initialSearchState(this.props.searchParams); state = initialSearchState(this.props.searchParams);
paginationRef = createRef(); paginationRef = createRef();
filterHeaderRef = createRef();
fillSpaceRef = createRef();
categoryFillSpaceRef = createRef();
toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
};
handleMeasurement = (count) => { handleMeasurement = (count) => {
if (!count || count === this.state.pageSize) return; if (!count || count === this.state.pageSize) return;
this.setState({ pageSize: count }); this.setState({ pageSize: count });
const { target, filter, offset } = queryFromParams(this.props.searchParams); const { filter, offset } = queryFromParams(this.props.searchParams);
const activeTarget = this.state.input;
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: target || this.state.input, target: activeTarget,
filter, filter,
offset, offset,
pageSize: count, pageSize: count,
@@ -118,7 +138,7 @@ class ArtikelSearchPage extends Component {
persistQuery = () => { persistQuery = () => {
const query = queryFromParams(this.props.searchParams); const query = queryFromParams(this.props.searchParams);
const target = query.target || this.state.input; const target = this.state.input;
writeArtikelQuery({ writeArtikelQuery({
target, target,
filter: query.filter, filter: query.filter,
@@ -132,6 +152,17 @@ class ArtikelSearchPage extends Component {
// Push search results into local component state. // Push search results into local component state.
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
// Initial search: kicks off index loading if idle and derives current items
const { filter, offset } = queryFromParams(this.props.searchParams);
const activeTarget = this.state.input;
setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset));
void articleSearchAutomat.actions.search({
target: activeTarget,
filter,
offset,
pageSize: this.state.pageSize,
}).catch(() => {});
} }
componentWillUnmount() { componentWillUnmount() {
@@ -139,21 +170,35 @@ class ArtikelSearchPage extends Component {
this.unsubSearch?.(); this.unsubSearch?.();
this.unsubSearch = null; this.unsubSearch = null;
this.persistQuery(); this.persistQuery();
const query = queryFromParams(this.props.searchParams);
const target = this.state.input;
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps, prevState) {
const { searchParams } = this.props; const { searchParams } = this.props;
const prev = prevProps.searchParams; const prev = prevProps.searchParams;
const target = searchParams.get('target') ?? ''; const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? ''; const prevTarget = prev.get('target') ?? '';
if (
this.state.categoryFilterOpen !== prevState?.categoryFilterOpen ||
searchParams.get('filter') !== prev.get('filter')
) {
requestAnimationFrame(() => {
this.fillSpaceRef.current?.measure();
this.categoryFillSpaceRef.current?.measure();
});
}
if (target !== prevTarget && target !== this.state.input) { if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target }); this.setState({ input: target });
this.fetchResults();
return;
} }
if ( if (
target !== prevTarget searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('offset') !== prev.get('offset') || searchParams.get('offset') !== prev.get('offset')
) { ) {
this.fetchResults(); this.fetchResults();
@@ -166,7 +211,9 @@ class ArtikelSearchPage extends Component {
filter: nextFilter, filter: nextFilter,
offset: nextOffset, offset: nextOffset,
}); });
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), { const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset);
setLastSearchTerm(nextTarget, nextUrl);
this.props.navigate(nextUrl, {
replace: true, replace: true,
}); });
}; };
@@ -176,29 +223,29 @@ class ArtikelSearchPage extends Component {
this.setState({ input: value }); this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams); const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state; const { pageSize } = this.state;
if (pageSize != null) { const currentUrl = buildSearchUrl(value, filter, 0);
void articleSearchAutomat.actions.search({ setLastSearchTerm(value, currentUrl);
target: value, writeArtikelQuery({ target: value, filter, offset: 0 });
filter, this.props.navigate(currentUrl, { replace: true });
offset: 0, void articleSearchAutomat.actions.search({
pageSize, target: value,
}).catch(() => {}); filter,
} offset: 0,
pageSize: pageSize ?? undefined,
}).catch(() => {});
}; };
fetchResults = () => { fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams); const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state; const { pageSize } = this.state;
if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset }); writeArtikelQuery({ target, filter, offset });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: target.trim(), target: target.trim(),
filter, filter,
offset, offset,
pageSize, pageSize: pageSize ?? undefined,
}).catch(() => {}); }).catch(() => {});
}; };
@@ -213,6 +260,7 @@ class ArtikelSearchPage extends Component {
render() { render() {
const { const {
input, input,
categoryFilterOpen,
error, error,
categoryFacets, categoryFacets,
totalCount, totalCount,
@@ -225,8 +273,6 @@ class ArtikelSearchPage extends Component {
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0); const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
const categories = parseFilter(filterParam); const categories = parseFilter(filterParam);
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
const rows = recordset ?? []; const rows = recordset ?? [];
const facetEntries = categoryFacets const facetEntries = categoryFacets
@@ -246,9 +292,18 @@ class ArtikelSearchPage extends Component {
return ( return (
<> <>
<Box sx={{ mb: 3, maxWidth: 720 }}> <Box
ref={this.filterHeaderRef}
sx={{
mb: 2,
display: 'flex',
gap: 1.5,
alignItems: 'center',
minHeight: 40,
}}
>
<TextField <TextField
fullWidth sx={{ maxWidth: 520, flexGrow: 1 }}
autoFocus autoFocus
type="search" type="search"
autoComplete="off" autoComplete="off"
@@ -261,6 +316,7 @@ class ArtikelSearchPage extends Component {
autoCorrect: 'off', autoCorrect: 'off',
}, },
input: { input: {
sx: { height: 40 },
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
<SearchIcon color="action" /> <SearchIcon color="action" />
@@ -269,6 +325,34 @@ class ArtikelSearchPage extends Component {
}, },
}} }}
/> />
{facetEntries.length > 0 && (
<Button
variant={categoryFilterOpen ? 'contained' : 'outlined'}
color="inherit"
onClick={this.toggleCategoryFilter}
startIcon={<FilterListIcon />}
endIcon={categoryFilterOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Kategorien
</Button>
)}
{categories.map((catId) => {
const catName = categoryFacets?.[catId]?.name ?? `Kategorie ${catId}`;
return (
<Chip
key={catId}
label={catName}
color="primary"
variant="outlined"
onDelete={() => {
const next = categories.filter((id) => id !== catId);
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
);
})}
</Box> </Box>
{error && ( {error && (
@@ -279,17 +363,47 @@ class ArtikelSearchPage extends Component {
/> />
)} )}
{searchActive && ( <Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}> {categoryFilterOpen && facetEntries.length > 0 && (
{facetEntries.length > 0 && ( <FillSpace
<Paper variant="outlined" sx={{ width: 220, flexShrink: 0, overflow: 'auto' }}> ref={this.categoryFillSpaceRef}
<Typography aboveRef={this.filterHeaderRef}
variant="subtitle2" sx={{ width: 240, flexShrink: 0 }}
sx={{ px: 2, py: 1.5, bgcolor: 'action.hover', fontWeight: 600 }} >
<Paper
variant="outlined"
sx={{
height: '100%',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.5,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
> >
Kategorien <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography> Kategorien
<List dense disablePadding> </Typography>
<IconButton
size="small"
onClick={this.toggleCategoryFilter}
title="Kategorien einklappen"
aria-label="Kategorien einklappen"
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
{facetEntries.map(({ id, count, name, depth }) => { {facetEntries.map(({ id, count, name, depth }) => {
const selected = categories.includes(id); const selected = categories.includes(id);
return ( return (
@@ -299,6 +413,7 @@ class ArtikelSearchPage extends Component {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
const next = selected ? [] : [id]; const next = selected ? [] : [id];
this.setState({ categoryFilterOpen: false });
this.updateParams(input, buildFilter(next), 0); this.updateParams(input, buildFilter(next), 0);
}} }}
sx={{ pl: 2 + depth * 1.5 }} sx={{ pl: 2 + depth * 1.5 }}
@@ -315,101 +430,102 @@ class ArtikelSearchPage extends Component {
})} })}
</List> </List>
</Paper> </Paper>
</FillSpace>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
{!error && input.trim().length > 0 && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)} )}
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}> {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
{searchActive && !error && totalCount === 0 && ( <Box ref={this.paginationRef} sx={{ mb: 2 }}>
<Typography color="text.secondary">Keine Artikel gefunden.</Typography> <ResultsPagination
)} invisible={!showPagination}
offset={showPagination ? offset : 0}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
<Box ref={this.paginationRef} sx={{ mb: 2 }}> pageSize={pageSize ?? 1}
<ResultsPagination pageCount={showPagination ? pageCount : 5}
invisible={!showPagination} currentPage={showPagination ? currentPage : 1}
offset={showPagination ? offset : 0} onPageChange={this.handlePageChange}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1} />
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
aboveRef={this.paginationRef}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
>
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
</Box> </Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => (
<ListItemButton
key={row.id}
type="button"
divider={index < rows.length - 1}
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
<ListItemText
primary={row.name}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
>
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}
>
{row.price != null ? formatPrice(row.price) : '—'}
</Typography>
</ListItemButton>
))}
</List>
)}
</Box>
</FillSpace>
</Box> </Box>
)} </Box>
</> </>
); );
} }

View File

@@ -28,7 +28,7 @@ import {
import categoryAutomat from '../../automats/categoryAutomat'; import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat'; import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku, setLastCategorySlug } from '../../automats';
import { import {
categoryDisplayName, categoryDisplayName,
getCategoryBreadcrumbs, getCategoryBreadcrumbs,
@@ -98,6 +98,7 @@ class ArtikelKategorienPage extends PureComponent {
componentDidMount() { componentDidMount() {
setLastArticleSku(null); setLastArticleSku(null);
setLastCategorySlug(this.props.categorySlugPath);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory); this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals); this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({ this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
@@ -110,6 +111,9 @@ class ArtikelKategorienPage extends PureComponent {
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (this.props.categorySlugPath !== prevProps.categorySlugPath) {
setLastCategorySlug(this.props.categorySlugPath);
}
if (this.state.companyId !== prevState.companyId) { if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded(); this.ensureTreeLoaded();
} }
@@ -132,6 +136,7 @@ class ArtikelKategorienPage extends PureComponent {
} }
componentWillUnmount() { componentWillUnmount() {
setLastCategorySlug(this.props.categorySlugPath);
this._unsubCat?.(); this._unsubCat?.();
this._unsubTerm?.(); this._unsubTerm?.();
this._unsubCompany?.(); this._unsubCompany?.();