u
This commit is contained in:
@@ -98,6 +98,7 @@ function scheduleRebuild() {
|
|||||||
function toIndexRecord(article, categoryId) {
|
function toIndexRecord(article, categoryId) {
|
||||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||||
return {
|
return {
|
||||||
|
...article,
|
||||||
id: article.id,
|
id: article.id,
|
||||||
sku: article.sku,
|
sku: article.sku,
|
||||||
name: article.name,
|
name: article.name,
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* @file articleSearchAutomat.js
|
* @file articleSearchAutomat.js
|
||||||
* @description In-memory article search state for the `/artikel` overview and
|
* @description In-memory article search and pagination state for the `/artikel`
|
||||||
* search pages, querying against the derived `articleIndexAutomat`.
|
* overview and search pages, querying against the derived `articleIndexAutomat`.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`.
|
* - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`.
|
||||||
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
|
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
|
||||||
* before filtering. The index is built from the loaded category views, so a
|
* before filtering and slicing.
|
||||||
* search never issues a request of its own.
|
* - `categoryAutomat`: Provides category hierarchy metadata for faceted counts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import articleIndexAutomat from './articleIndexAutomat';
|
import articleIndexAutomat from './articleIndexAutomat';
|
||||||
|
import categoryAutomat from './categoryAutomat';
|
||||||
|
import { parseFilter } from '../components/article/searchFormat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ArticleSearchResult
|
* @typedef {Object} ArticleSearchResult
|
||||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||||
* @property {number} totalCount Number of matching records.
|
* @property {number} totalCount Number of matching records.
|
||||||
* @property {Array<object>} recordset Matching article index records.
|
* @property {Array<object>} recordset Sliced matching article index records for the current page.
|
||||||
|
* @property {number} offset Current 0-based offset.
|
||||||
|
* @property {number|null} pageSize Current page size.
|
||||||
|
* @property {number} pageCount Total number of pages.
|
||||||
|
* @property {number} currentPage Current 1-based page number.
|
||||||
* @property {Error|null} error Search failure, or `null`.
|
* @property {Error|null} error Search failure, or `null`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,6 +31,9 @@ import articleIndexAutomat from './articleIndexAutomat';
|
|||||||
* @typedef {Object} ArticleSearchState
|
* @typedef {Object} ArticleSearchState
|
||||||
* @property {ArticleSearchResult} result Latest search outcome.
|
* @property {ArticleSearchResult} result Latest search outcome.
|
||||||
* @property {string} target Active search term.
|
* @property {string} target Active search term.
|
||||||
|
* @property {string} filter Active category filter string.
|
||||||
|
* @property {number} offset Current 0-based offset.
|
||||||
|
* @property {number|null} pageSize Current page size.
|
||||||
* @property {boolean} loading Whether a search is in flight.
|
* @property {boolean} loading Whether a search is in flight.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -33,6 +42,10 @@ const emptyResult = {
|
|||||||
categoryFacets: {},
|
categoryFacets: {},
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
recordset: [],
|
recordset: [],
|
||||||
|
offset: 0,
|
||||||
|
pageSize: null,
|
||||||
|
pageCount: 1,
|
||||||
|
currentPage: 1,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,73 +53,210 @@ const emptyResult = {
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
result: emptyResult,
|
result: emptyResult,
|
||||||
target: '',
|
target: '',
|
||||||
|
filter: '',
|
||||||
|
offset: 0,
|
||||||
|
pageSize: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let articleSearchAutomat;
|
let articleSearchAutomat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches one index record against a lowercased search term.
|
* Matches one index record against a search query (checks tokens in name, sku, brand, eans).
|
||||||
*
|
*
|
||||||
* @param {object} row Article index record.
|
* @param {object} row Article index record.
|
||||||
* @param {string} query Lowercased search term.
|
* @param {string} query Lowercased search term.
|
||||||
* @returns {boolean} Whether the record matches.
|
* @returns {boolean} Whether the record matches.
|
||||||
*/
|
*/
|
||||||
function matches(row, query) {
|
function matches(row, query) {
|
||||||
if (String(row.name ?? '').toLowerCase().includes(query)) return true;
|
if (!query) return true;
|
||||||
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
|
const tokens = query.split(/\s+/).filter(Boolean);
|
||||||
return (row.eans ?? []).some((ean) => String(ean).includes(query));
|
if (tokens.length === 0) return true;
|
||||||
|
const name = String(row.name ?? '').toLowerCase();
|
||||||
|
const sku = String(row.sku ?? '').toLowerCase();
|
||||||
|
const brand = String(row.brand?.name || row.brandName || '').toLowerCase();
|
||||||
|
const eans = (row.eans ?? []).map((e) => String(e).toLowerCase());
|
||||||
|
return tokens.every(
|
||||||
|
(token) =>
|
||||||
|
name.includes(token) ||
|
||||||
|
sku.includes(token) ||
|
||||||
|
brand.includes(token) ||
|
||||||
|
eans.some((ean) => ean.includes(token)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether an article belongs to at least one of the selected categories.
|
||||||
|
*
|
||||||
|
* @param {object} row Article index record.
|
||||||
|
* @param {Array<string>} selectedCategories Array of category ID strings.
|
||||||
|
* @returns {boolean} Whether the article matches.
|
||||||
|
*/
|
||||||
|
function matchesCategory(row, selectedCategories) {
|
||||||
|
if (!selectedCategories || selectedCategories.length === 0) return true;
|
||||||
|
if (!row.categories) return false;
|
||||||
|
const rowCats = String(row.categories).split(',').map((s) => s.trim());
|
||||||
|
return selectedCategories.some((catId) => rowCats.includes(String(catId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes facet counts for each category represented in matched articles.
|
||||||
|
*
|
||||||
|
* @param {Array<object>} matchedArticles Articles matching text query.
|
||||||
|
* @returns {Record<string, {count: number, name: string, parent: any}>} Category facets.
|
||||||
|
*/
|
||||||
|
function buildCategoryFacets(matchedArticles) {
|
||||||
|
const facets = {};
|
||||||
|
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||||
|
|
||||||
|
for (const article of matchedArticles) {
|
||||||
|
if (!article.categories) continue;
|
||||||
|
const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!facets[id]) {
|
||||||
|
const catNode = categoriesMap[id];
|
||||||
|
facets[id] = {
|
||||||
|
count: 0,
|
||||||
|
name: catNode?.name ?? `Kategorie ${id}`,
|
||||||
|
parent: catNode?.parentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
facets[id].count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return facets;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
* Runs a client-side search over the article index.
|
* Runs a client-side search over the article index with pagination.
|
||||||
*
|
*
|
||||||
* The index is already derived from the cached category views, so this does
|
* @param {string|object} targetOrParams Search term or options object.
|
||||||
* not hit the network once the tree has loaded.
|
* @param {object} [options] Secondary options when first argument is target string.
|
||||||
*
|
|
||||||
* @param {string} target Search term; an empty string returns everything.
|
|
||||||
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
||||||
*/
|
*/
|
||||||
async search(target) {
|
async search(targetOrParams, options = {}) {
|
||||||
articleSearchAutomat.setState({ target, loading: true });
|
const currentState = articleSearchAutomat.state;
|
||||||
|
const params = typeof targetOrParams === 'object' && targetOrParams !== null
|
||||||
|
? targetOrParams
|
||||||
|
: { target: targetOrParams, ...options };
|
||||||
|
|
||||||
|
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
|
||||||
|
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||||
|
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||||
|
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||||
|
|
||||||
|
articleSearchAutomat.setState({ target, filter, offset, pageSize, loading: true });
|
||||||
try {
|
try {
|
||||||
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
||||||
const query = String(target ?? '').trim().toLowerCase();
|
const query = target.trim().toLowerCase();
|
||||||
const recordset = query
|
|
||||||
|
// Filter by text query across all tokens
|
||||||
|
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)
|
||||||
|
const categoryFacets = buildCategoryFacets(textMatches);
|
||||||
|
|
||||||
|
// Apply category filter if set
|
||||||
|
const selectedCategories = parseFilter(filter);
|
||||||
|
const filteredArticles = selectedCategories.length > 0
|
||||||
|
? textMatches.filter((row) => matchesCategory(row, selectedCategories))
|
||||||
|
: textMatches;
|
||||||
|
|
||||||
|
const totalCount = filteredArticles.length;
|
||||||
|
const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null;
|
||||||
|
const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1;
|
||||||
|
const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1;
|
||||||
|
const recordset = safePageSize
|
||||||
|
? filteredArticles.slice(offset, offset + safePageSize)
|
||||||
|
: filteredArticles;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
categoryFacets: {},
|
categoryFacets,
|
||||||
totalCount: recordset.length,
|
totalCount,
|
||||||
recordset,
|
recordset,
|
||||||
|
offset,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
articleSearchAutomat.setState({ result, loading: false });
|
articleSearchAutomat.setState({ result, loading: false });
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result = { ...emptyResult, error };
|
const result = {
|
||||||
|
...emptyResult,
|
||||||
|
pageSize,
|
||||||
|
offset,
|
||||||
|
error,
|
||||||
|
};
|
||||||
articleSearchAutomat.setState({ result, loading: false });
|
articleSearchAutomat.setState({ result, loading: false });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset the result set. */
|
/**
|
||||||
|
* Updates the page size and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} pageSize Number of articles per page.
|
||||||
|
* @returns {Promise<ArticleSearchResult>}
|
||||||
|
*/
|
||||||
|
setPageSize(pageSize) {
|
||||||
|
const { target, filter, offset } = articleSearchAutomat.state;
|
||||||
|
return actions.search({ target, filter, offset, pageSize });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets page number (1-based) and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} page 1-based page number.
|
||||||
|
* @returns {Promise<ArticleSearchResult>}
|
||||||
|
*/
|
||||||
|
setPage(page) {
|
||||||
|
const { target, filter, pageSize } = articleSearchAutomat.state;
|
||||||
|
const safeSize = pageSize ?? 1;
|
||||||
|
const offset = Math.max(0, (page - 1) * safeSize);
|
||||||
|
return actions.search({ target, filter, offset, pageSize });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets 0-based offset and re-derives the result set.
|
||||||
|
*
|
||||||
|
* @param {number} offset 0-based record offset.
|
||||||
|
* @returns {Promise<ArticleSearchResult>}
|
||||||
|
*/
|
||||||
|
setOffset(offset) {
|
||||||
|
const { target, filter, pageSize } = articleSearchAutomat.state;
|
||||||
|
return actions.search({ target, filter, offset, pageSize });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset the search state. */
|
||||||
reset() {
|
reset() {
|
||||||
articleSearchAutomat.setState(initialState);
|
articleSearchAutomat.setState(initialState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Article search state for the `/artikel` overview page.
|
* Article search and pagination state for the `/artikel` overview page.
|
||||||
*
|
*
|
||||||
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
|
* Components subscribe to `result` to receive paginated records, facets,
|
||||||
* instead of receiving a callback.
|
* page counts, and total counts.
|
||||||
*
|
*
|
||||||
* @type {Automat<ArticleSearchState>}
|
* @type {Automat<ArticleSearchState>}
|
||||||
*/
|
*/
|
||||||
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
||||||
|
|
||||||
|
articleIndexAutomat.subscribe(() => {
|
||||||
|
const { target, filter, offset, pageSize, loading } = articleSearchAutomat.state;
|
||||||
|
if (pageSize != null && !loading && articleIndexAutomat.state.status === 'ready') {
|
||||||
|
void actions.search({ target, filter, offset, pageSize }).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects the latest search result.
|
* Selects the latest search result.
|
||||||
*
|
*
|
||||||
|
|||||||
13
src/main.jsx
13
src/main.jsx
@@ -37,6 +37,19 @@ if (import.meta.env.DEV) {
|
|||||||
void clearDevServiceWorker();
|
void clearDevServiceWorker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PWA auto-update: reload the page as soon as a new build's service worker
|
||||||
|
// takes control. The virtual module only exists in production builds (the
|
||||||
|
// plugin is registered for `build` only), hence the dynamic import.
|
||||||
|
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||||
|
import('virtual:pwa-register')
|
||||||
|
.then(({ registerSW }) => {
|
||||||
|
registerSW({ immediate: true });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No SW in this build — app still works, just without offline support.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<ThemeProvider theme={getActiveTheme()}>
|
<ThemeProvider theme={getActiveTheme()}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ import { artikelDetailPath } from '../components/article/articleRoute';
|
|||||||
import withSearchParams from '../components/article/withSearchParams';
|
import withSearchParams from '../components/article/withSearchParams';
|
||||||
import { setLastArticleSku } from '../automats';
|
import { setLastArticleSku } from '../automats';
|
||||||
import { BrandLabel } from '../components/brand';
|
import { BrandLabel } from '../components/brand';
|
||||||
import {
|
|
||||||
BLUE_BG,
|
|
||||||
} from '../colors';
|
|
||||||
|
|
||||||
const resultRowSx = {
|
const resultRowSx = {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -50,18 +47,31 @@ const resultRowSx = {
|
|||||||
|
|
||||||
function ResultRowMeasureSample() {
|
function ResultRowMeasureSample() {
|
||||||
return (
|
return (
|
||||||
<ListItemButton tabIndex={-1} divider sx={resultRowSx}>
|
<ListItemButton
|
||||||
|
tabIndex={-1}
|
||||||
|
divider
|
||||||
|
disabled
|
||||||
|
sx={{
|
||||||
|
...resultRowSx,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
opacity: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary="Measure"
|
primary={'\u00A0'}
|
||||||
secondary={
|
secondary={
|
||||||
<Typography component="span" variant="body2" color="text.secondary" noWrap>
|
<Typography component="span" variant="body2" sx={{ visibility: 'hidden' }} noWrap>
|
||||||
SKU · Bestand 0 · Verfügbar 0 · Kategorie
|
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}>
|
<Typography
|
||||||
0,00 €
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
|
||||||
|
>
|
||||||
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
@@ -75,87 +85,35 @@ function initialSearchState(searchParams) {
|
|||||||
? query.target
|
? query.target
|
||||||
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
|
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
|
||||||
|
|
||||||
|
const automatResult = articleSearchAutomat.state.result;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
error: null,
|
error: automatResult.error,
|
||||||
categoryFacets: null,
|
categoryFacets: automatResult.categoryFacets,
|
||||||
totalCount: null,
|
totalCount: automatResult.totalCount,
|
||||||
recordset: null,
|
recordset: automatResult.recordset,
|
||||||
pageSize: null,
|
pageSize: automatResult.pageSize,
|
||||||
|
pageCount: automatResult.pageCount,
|
||||||
|
currentPage: automatResult.currentPage,
|
||||||
|
offset: automatResult.offset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ArtikelSearchPage extends Component {
|
class ArtikelSearchPage extends Component {
|
||||||
state = initialSearchState(this.props.searchParams);
|
state = initialSearchState(this.props.searchParams);
|
||||||
listSlotRef = { current: null };
|
|
||||||
rowMeasureRef = { current: null };
|
|
||||||
paginationRef = createRef();
|
paginationRef = createRef();
|
||||||
|
|
||||||
setListSlotRef = (node) => {
|
handleMeasurement = (count) => {
|
||||||
if (this.listSlotRef.current === node) return;
|
if (!count || count === this.state.pageSize) return;
|
||||||
this.listSlotRef.current = node;
|
this.setState({ pageSize: count });
|
||||||
if (!node) return;
|
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||||
this.updatePageSize();
|
void articleSearchAutomat.actions.search({
|
||||||
};
|
target: target || this.state.input,
|
||||||
|
filter,
|
||||||
setRowMeasureRef = (node) => {
|
offset,
|
||||||
if (this.rowMeasureRef.current === node) return;
|
pageSize: count,
|
||||||
this.rowMeasureRef.current = node;
|
}).catch(() => {});
|
||||||
if (!node) return;
|
|
||||||
this.updatePageSize();
|
|
||||||
};
|
|
||||||
|
|
||||||
measureRowHeight = () => {
|
|
||||||
// Always use the sample — real rows used to report a different height after paint
|
|
||||||
// (wrapped text), which made resize "fix" a wrong first measure.
|
|
||||||
const sample = this.rowMeasureRef.current;
|
|
||||||
if (!sample) return { height: 0, source: 'none' };
|
|
||||||
return {
|
|
||||||
height: sample.getBoundingClientRect().height,
|
|
||||||
source: 'sample',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePageSize = ({ fromResize = false } = {}) => {
|
|
||||||
const slot = this.listSlotRef.current;
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
// After the first measure, ignore layout churn from painting results.
|
|
||||||
// Only window resize may change pageSize and refetch.
|
|
||||||
if (this.state.pageSize != null && !fromResize) return;
|
|
||||||
|
|
||||||
const slotTop = slot.getBoundingClientRect().top;
|
|
||||||
const available = window.innerHeight - slotTop;
|
|
||||||
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
|
|
||||||
if (available < 1 || rowHeight < 1) return;
|
|
||||||
|
|
||||||
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
|
|
||||||
const listChrome = 2;
|
|
||||||
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
|
|
||||||
|
|
||||||
console.log('[pageSize] measure', {
|
|
||||||
fromResize,
|
|
||||||
prevPageSize: this.state.pageSize,
|
|
||||||
next,
|
|
||||||
available,
|
|
||||||
rowHeight,
|
|
||||||
rowSource,
|
|
||||||
listChrome,
|
|
||||||
slotTop,
|
|
||||||
realRowHeight:
|
|
||||||
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (next === this.state.pageSize) return;
|
|
||||||
|
|
||||||
this.setState({ pageSize: next }, () => {
|
|
||||||
const target = this.props.searchParams.get('target') ?? '';
|
|
||||||
if (target) this.fetchResults();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleWindowResize = () => {
|
|
||||||
this.updatePageSize({ fromResize: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
persistQuery = () => {
|
persistQuery = () => {
|
||||||
@@ -170,19 +128,14 @@ class ArtikelSearchPage extends Component {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
document.addEventListener('mouseup', preserveSearchFocus, true);
|
document.addEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.addEventListener('resize', this.handleWindowResize);
|
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
// Kick off initial search (empty input = show all products).
|
|
||||||
void articleSearchAutomat.actions.search(this.state.input).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.removeEventListener('resize', this.handleWindowResize);
|
|
||||||
this.unsubSearch?.();
|
this.unsubSearch?.();
|
||||||
this.unsubSearch = null;
|
this.unsubSearch = null;
|
||||||
this.persistQuery();
|
this.persistQuery();
|
||||||
@@ -221,7 +174,16 @@ class ArtikelSearchPage extends Component {
|
|||||||
handleInputChange = (event) => {
|
handleInputChange = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
this.setState({ input: value });
|
this.setState({ input: value });
|
||||||
void articleSearchAutomat.actions.search(value).catch(() => {});
|
const { filter } = queryFromParams(this.props.searchParams);
|
||||||
|
const { pageSize } = this.state;
|
||||||
|
if (pageSize != null) {
|
||||||
|
void articleSearchAutomat.actions.search({
|
||||||
|
target: value,
|
||||||
|
filter,
|
||||||
|
offset: 0,
|
||||||
|
pageSize,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchResults = () => {
|
fetchResults = () => {
|
||||||
@@ -232,13 +194,36 @@ class ArtikelSearchPage extends Component {
|
|||||||
|
|
||||||
writeArtikelQuery({ target, filter, offset });
|
writeArtikelQuery({ target, filter, offset });
|
||||||
|
|
||||||
void articleSearchAutomat.actions.search(target.trim()).catch(() => {});
|
void articleSearchAutomat.actions.search({
|
||||||
|
target: target.trim(),
|
||||||
|
filter,
|
||||||
|
offset,
|
||||||
|
pageSize,
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePageChange = (_e, page) => {
|
||||||
|
const { pageSize } = this.state;
|
||||||
|
const safePageSize = pageSize ?? 1;
|
||||||
|
const nextOffset = (page - 1) * safePageSize;
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
this.updateParams(this.state.input, filterParam, nextOffset);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
|
const {
|
||||||
|
input,
|
||||||
|
error,
|
||||||
|
categoryFacets,
|
||||||
|
totalCount,
|
||||||
|
recordset,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
currentPage,
|
||||||
|
offset: stateOffset,
|
||||||
|
} = this.state;
|
||||||
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')) || 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).
|
// 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 searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
|
||||||
@@ -256,16 +241,9 @@ class ArtikelSearchPage extends Component {
|
|||||||
.sort((a, b) => Number(a.id) - Number(b.id))
|
.sort((a, b) => Number(a.id) - Number(b.id))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const safePageSize = pageSize ?? 1;
|
|
||||||
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
|
|
||||||
const currentPage = Math.floor(offset / safePageSize) + 1;
|
|
||||||
const showPagination =
|
const showPagination =
|
||||||
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
|
||||||
|
|
||||||
const handlePageChange = (_e, page) => {
|
|
||||||
this.updateParams(input, filterParam, (page - 1) * safePageSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ mb: 3, maxWidth: 720 }}>
|
<Box sx={{ mb: 3, maxWidth: 720 }}>
|
||||||
@@ -340,16 +318,6 @@ class ArtikelSearchPage extends Component {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
|
||||||
{/* Same column width as the list; invisible, zero layout footprint. */}
|
|
||||||
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}>
|
|
||||||
<Box
|
|
||||||
ref={this.setRowMeasureRef}
|
|
||||||
sx={{ visibility: 'hidden', pointerEvents: 'none' }}
|
|
||||||
>
|
|
||||||
<ResultRowMeasureSample />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{searchActive && !error && totalCount === 0 && (
|
{searchActive && !error && totalCount === 0 && (
|
||||||
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
|
||||||
)}
|
)}
|
||||||
@@ -363,29 +331,17 @@ class ArtikelSearchPage extends Component {
|
|||||||
pageSize={pageSize ?? 1}
|
pageSize={pageSize ?? 1}
|
||||||
pageCount={showPagination ? pageCount : 5}
|
pageCount={showPagination ? pageCount : 5}
|
||||||
currentPage={showPagination ? currentPage : 1}
|
currentPage={showPagination ? currentPage : 1}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={this.handlePageChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FillSpace
|
<FillSpace
|
||||||
aboveRef={this.paginationRef}
|
aboveRef={this.paginationRef}
|
||||||
debug
|
onMeasurement={this.handleMeasurement}
|
||||||
sx={{ overflow: 'auto' }}
|
sx={{ overflow: 'auto' }}
|
||||||
// Temporary dummy unit so the repeating fill is visible.
|
measureElement={<ResultRowMeasureSample />}
|
||||||
measureElement={
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: BLUE_BG,
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2">Einheit</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Box ref={this.setListSlotRef}>
|
<Box>
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<List component={Paper} variant="outlined" disablePadding>
|
<List component={Paper} variant="outlined" disablePadding>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
|
|||||||
@@ -289,8 +289,13 @@ export default defineConfig(({ command }) => ({
|
|||||||
...(command === 'build'
|
...(command === 'build'
|
||||||
? [
|
? [
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
// autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a
|
||||||
|
// new build takes over every open tab on its own.
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
injectRegister: 'auto',
|
// Registration is done explicitly in src/main.jsx via
|
||||||
|
// `virtual:pwa-register` with `immediate: true`; that is what makes
|
||||||
|
// the *page* reload (a bare injected script only swaps the SW).
|
||||||
|
injectRegister: false,
|
||||||
manifest: {
|
manifest: {
|
||||||
id: '/',
|
id: '/',
|
||||||
name: 'Dashfront',
|
name: 'Dashfront',
|
||||||
@@ -320,6 +325,13 @@ export default defineConfig(({ command }) => ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
|
// NOTE: vite-plugin-pwa only auto-sets these two when
|
||||||
|
// injectRegister === 'auto'. Because we register manually in
|
||||||
|
// src/main.jsx (injectRegister: false), they must be explicit —
|
||||||
|
// otherwise a new SW waits forever behind the old one and no tab
|
||||||
|
// is ever claimed.
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
|
||||||
globIgnores: ['**/*.wasm'],
|
globIgnores: ['**/*.wasm'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
@@ -327,7 +339,19 @@ export default defineConfig(({ command }) => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: [
|
||||||
|
{
|
||||||
|
name: 'virtual-pwa-register-stub',
|
||||||
|
resolveId(id) {
|
||||||
|
if (id === 'virtual:pwa-register') return '\0virtual:pwa-register';
|
||||||
|
},
|
||||||
|
load(id) {
|
||||||
|
if (id === '\0virtual:pwa-register') {
|
||||||
|
return 'export function registerSW() { return () => {}; }';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
server: sharedServer,
|
server: sharedServer,
|
||||||
preview: sharedServer,
|
preview: sharedServer,
|
||||||
|
|||||||
Reference in New Issue
Block a user