This commit is contained in:
seb
2026-10-10 02:37:38 +02:00
parent 859fec808d
commit c5b46f11f1
7 changed files with 461 additions and 470 deletions

View File

@@ -27,7 +27,6 @@ import {
readArtikelQuery,
writeArtikelQuery,
} from '../components/article/artikelQuery';
import ResultsPagination from '../components/article/ResultsPagination';
import {
buildFilter,
formatCategoryList,
@@ -82,36 +81,161 @@ const resultRowSx = {
},
};
function ResultRowMeasureSample() {
return (
<ListItemButton
tabIndex={-1}
divider
disabled
sx={{
...resultRowSx,
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.2,
}}
>
<ListItemText
primary={'\u00A0'}
secondary={
<Typography component="span" variant="body2" sx={{ visibility: 'hidden', lineHeight: '20px', height: '20px' }} noWrap>
/**
* Ambient context for the `renderItem` row. FillSpace renders rows as a bare
* element (`<Row brand={item} item={item} index={index} />`, probe: `item`
* undefined) and cannot pass page callbacks, so the page publishes them here
* on every render.
*/
const resultRowsContext = { categoryFacets: {}, onOpenArticle: null };
/**
* One search-result row rendered by FillSpace via `renderItem`. A class
* component so FillSpace renders it as an element (and can probe it with
* `item` undefined to measure the row height).
*/
class ResultRow extends Component {
render() {
const { item: row } = this.props;
const { categoryFacets, onOpenArticle } = resultRowsContext;
// Data-free probe (`item` undefined): fixed-height placeholder so FillSpace
// can measure the row without touching real data.
if (!row) {
return (
<ListItemButton
tabIndex={-1}
divider
disabled
sx={{ ...resultRowSx, pointerEvents: 'none', userSelect: 'none', opacity: 0 }}
>
<ListItemText
primary={'\u00A0'}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
>
&nbsp;
</Typography>
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
<Typography
variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
</ListItemButton>
);
}
const isParent = Boolean(row.isParent || row.is_parent);
const childCount = Number(row.childCount) || 0;
const showChildBadge = isParent || childCount > 0;
const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
return (
<ListItemButton
type="button"
divider
data-result-row
onMouseDown={(e) => e.preventDefault()}
onClick={() => onOpenArticle?.(row)}
sx={resultRowSx}
>
&nbsp;
</Typography>
</ListItemButton>
);
<ListItemText
primary={
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
maxWidth: '100%',
}}
>
<Box
component="span"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.name}
</Box>
{showChildBadge && (
<Box
component="span"
title={`${childCount} Varianten`}
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 0.75,
py: 0.25,
borderRadius: 999,
bgcolor: SCRIM_LABEL,
color: 'common.white',
fontSize: 11,
fontWeight: 600,
lineHeight: 1.2,
whiteSpace: 'nowrap',
flexShrink: 0,
pointerEvents: 'none',
}}
>
{childCount}
</Box>
)}
</Box>
}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
maxWidth: '100%',
lineHeight: '20px',
height: '20px',
}}
>
{(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 }}
>
{price != null ? formatPrice(price) : '—'}
</Typography>
</ListItemButton>
);
}
}
// #endregion
@@ -172,30 +296,19 @@ class ArtikelSearchPage extends Component {
constructor(props) {
super(props);
this.state = initialSearchState(props.searchParams);
this.paginationRef = createRef();
this.filterHeaderRef = createRef();
this.fillSpaceRef = createRef();
this.categoryFillSpaceRef = createRef();
}
// #endregion
// #region Measurement & Query Persistence
// #region Query Persistence
toggleCategoryFilter = () => {
this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen }));
};
handleMeasurement = (count) => {
if (!count || count === this.state.pageSize) return;
this.setState({ pageSize: count });
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: count,
}).catch(() => {});
handleOpenArticle = (row) => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
};
persistQuery = () => {
@@ -238,8 +351,7 @@ class ArtikelSearchPage extends Component {
void articleSearchAutomat.actions.search({
target: activeTarget,
filter,
offset,
pageSize: this.state.pageSize,
offset: 0,
}).catch(() => {});
}
@@ -253,22 +365,12 @@ class ArtikelSearchPage extends Component {
setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset));
}
componentDidUpdate(prevProps, prevState) {
componentDidUpdate(prevProps) {
const { searchParams } = this.props;
const prev = prevProps.searchParams;
const target = searchParams.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) {
this.setState({ input: target });
this.fetchResults();
@@ -316,7 +418,6 @@ class ArtikelSearchPage extends Component {
const value = event.target.value;
this.setState({ input: value });
const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
const currentUrl = buildSearchUrl(value, filter, 0);
setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, offset: 0 });
@@ -325,7 +426,6 @@ class ArtikelSearchPage extends Component {
target: value,
filter,
offset: 0,
pageSize: pageSize ?? undefined,
}).catch(() => {});
};
@@ -335,7 +435,6 @@ class ArtikelSearchPage extends Component {
fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
writeArtikelQuery({ target, filter, offset });
@@ -343,17 +442,8 @@ class ArtikelSearchPage extends Component {
target: target.trim(),
filter,
offset,
pageSize: pageSize ?? undefined,
}).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);
};
// #endregion
// #region Render
@@ -362,16 +452,11 @@ class ArtikelSearchPage extends Component {
input,
categoryFilterOpen,
error,
categoryFacets,
totalCount,
categoryFacets,
recordset,
pageSize,
pageCount,
currentPage,
offset: stateOffset,
} = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
const categories = parseFilter(filterParam);
const rows = recordset ?? [];
@@ -408,8 +493,9 @@ class ArtikelSearchPage extends Component {
})
: [];
const showPagination =
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
// Per-render values the static `renderItem` row reads through the context.
resultRowsContext.categoryFacets = categoryFacets ?? {};
resultRowsContext.onOpenArticle = this.handleOpenArticle;
return (
<>
@@ -502,10 +588,13 @@ class ArtikelSearchPage extends Component {
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start', mb: -4 }}>
{categoryFilterOpen && facetEntries.length > 0 && (
<FillSpace
ref={this.categoryFillSpaceRef}
aboveRef={this.filterHeaderRef}
sx={{ width: 240, flexShrink: 0 }}
<Box
sx={{
width: 240,
flexShrink: 0,
alignSelf: 'stretch',
maxHeight: 'calc(100vh - 220px)',
}}
>
<Paper
variant="outlined"
@@ -567,7 +656,7 @@ class ArtikelSearchPage extends Component {
})}
</List>
</Paper>
</FillSpace>
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
@@ -575,150 +664,26 @@ class ArtikelSearchPage extends Component {
<Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
<Box ref={this.paginationRef} sx={{ mb: 2 }}>
<ResultsPagination
invisible={!showPagination}
offset={showPagination ? offset : 0}
totalCount={showPagination ? totalCount : (pageSize ?? 1) + 1}
pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef, this.paginationRef]}
onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }}
measureElement={<ResultRowMeasureSample />}
{/* Paper surface like /artikel/marken (PanelTile): the FillSpace
pagination header + rows render on an elevated card. */}
<Paper
variant="outlined"
sx={{
bgcolor: 'background.paper',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box>
{rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => {
const isParent = Boolean(row.isParent || row.is_parent);
const childCount = Number(row.childCount) || 0;
const showChildBadge = isParent || childCount > 0;
const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
return (
<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={
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
maxWidth: '100%',
}}
>
<Box
component="span"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.name}
</Box>
{showChildBadge && (
<Box
component="span"
title={`${childCount} Varianten`}
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
px: 0.75,
py: 0.25,
borderRadius: 999,
bgcolor: SCRIM_LABEL,
color: 'common.white',
fontSize: 11,
fontWeight: 600,
lineHeight: 1.2,
whiteSpace: 'nowrap',
flexShrink: 0,
pointerEvents: 'none',
}}
>
{childCount}
</Box>
)}
</Box>
}
secondary={
<Typography
component="span"
variant="body2"
color="text.secondary"
noWrap
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
maxWidth: '100%',
lineHeight: '20px',
height: '20px',
}}
>
{(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 }}
>
{price != null ? formatPrice(price) : '—'}
</Typography>
</ListItemButton>
);
})}
</List>
)}
</Box>
</FillSpace>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={[this.filterHeaderRef]}
items={rows}
renderItem={ResultRow}
minPageSize={8}
sx={{ overflow: 'auto' }}
/>
</Paper>
</Box>
</Box>
</>