u
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
Reference in New Issue
Block a user