This commit is contained in:
seb
2026-08-03 23:43:57 +02:00
parent d47f0ffa0d
commit 8f371749c6
4 changed files with 122 additions and 75 deletions

View File

@@ -0,0 +1,83 @@
import { createContext, useContext, useRef } from 'react';
export const StorageManagerContext = createContext(null);
function createStorageManager() {
/** Fetch /articleIndex on construction and convert to the row shape the search page renders. */
const articleIndexPromise = fetch('/api/articleIndex')
.then(async (res) => {
const data = await res.json();
const articles = Array.isArray(data) ? data : (data?.articles ?? []);
console.log('[storageMgr] GET /articleIndex', {
status: res.status,
count: articles.length,
sampleKeys:
articles.slice(0, 3).map((a) => (a && typeof a === 'object' ? Object.keys(a) : null)),
});
// Map backend article rows to the recordset shape used by the result list.
// `categories` is a comma-joined string (formatCategoryList splits on ',').
return articles.map((a) => ({
id: a.id,
sku: a.sku,
name: a.name,
eans: a.eans ?? [],
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
}));
})
.catch((err) => {
console.error('[storageMgr] GET /articleIndex failed', err);
return [];
});
async function doSearch({ target, filter, offset, maxCount, onUpdate }) {
const articles = await articleIndexPromise;
const query = target.trim().toLowerCase();
const matches = articles.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.sku.toLowerCase().includes(query) ||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
);
onUpdate({
categoryFacets: {},
totalCount: matches.length,
recordset: matches,
error: null,
});
}
return {
doSearch,
// Expose so callers can await if they want the loaded index.
articleIndexPromise,
};
}
/**
* Provider — mount once near the top of the tree.
*/
export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null);
if (!mgrRef.current) {
mgrRef.current = createStorageManager();
}
return (
<StorageManagerContext.Provider value={mgrRef.current}>
{children}
</StorageManagerContext.Provider>
);
}
/**
* Hook for function components that need the store manager.
*/
export function useStorageManager() {
const ctx = useContext(StorageManagerContext);
if (!ctx) {
throw new Error('useStorageManager must be used within a <StorageManagerProvider>');
}
return ctx;
}
export default StorageManagerContext;

View File

@@ -23,7 +23,7 @@ const articleNameFieldSx = {
const articleNameFieldHiddenSx = { const articleNameFieldHiddenSx = {
...articleNameFieldSx, ...articleNameFieldSx,
pointerEvents: 'none', userSelect: 'text',
'& .MuiOutlinedInput-notchedOutline': { '& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent', borderColor: 'transparent',
}, },

View File

@@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { SnackbarProvider } from 'notistack'; import { SnackbarProvider } from 'notistack';
import AuthBootstrap from './components/AuthBootstrap'; import AuthBootstrap from './components/AuthBootstrap';
import { StorageManagerProvider } from './api/storageManager';
import App from './App'; import App from './App';
import { store } from './store'; import { store } from './store';
import { getActiveTheme } from './theme'; import { getActiveTheme } from './theme';
@@ -33,7 +34,9 @@ createRoot(document.getElementById('root')).render(
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
> >
<AuthBootstrap> <AuthBootstrap>
<StorageManagerProvider>
<App /> <App />
</StorageManagerProvider>
</AuthBootstrap> </AuthBootstrap>
</SnackbarProvider> </SnackbarProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -9,13 +9,11 @@ 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 { article } from '../../api'; import { StorageManagerContext } from '../../api/storageManager';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { backendErrorFrom } from '../../utils/backendError';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { import {
buildSearchUrl, buildSearchUrl,
clearArtikelQuery,
queryFromParams, queryFromParams,
readArtikelQuery, readArtikelQuery,
writeArtikelQuery, writeArtikelQuery,
@@ -82,6 +80,8 @@ function initialSearchState(searchParams) {
} }
class ArtikelSearchPage extends Component { class ArtikelSearchPage extends Component {
static contextType = StorageManagerContext;
state = initialSearchState(this.props.searchParams); state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null }; listSlotRef = { current: null };
rowMeasureRef = { current: null }; rowMeasureRef = { current: null };
@@ -145,7 +145,7 @@ class ArtikelSearchPage extends Component {
this.setState({ pageSize: next }, () => { this.setState({ pageSize: next }, () => {
const target = this.props.searchParams.get('target') ?? ''; const target = this.props.searchParams.get('target') ?? '';
if (target.trim().length >= 3) this.fetchResults(); if (target) this.fetchResults();
}); });
}; };
@@ -166,6 +166,16 @@ class ArtikelSearchPage extends Component {
componentDidMount() { componentDidMount() {
document.addEventListener('mousedown', preserveSearchFocus, true); document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize); window.addEventListener('resize', this.handleWindowResize);
// Kick off initial search (empty input = show all products).
const { input } = this.state;
this.context.doSearch({
target: input,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
maxCount: this.state.pageSize ?? 20,
onUpdate: (result) => this.setState(result),
});
} }
componentWillUnmount() { componentWillUnmount() {
@@ -206,78 +216,35 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; const value = event.target.value;
const belowMin = value.trim().length < 3;
if (belowMin) {
clearArtikelQuery();
// Clear results in the same update that shortens the input — otherwise the
// hint appears one paint before fetchResults (driven by URL) removes the list.
this.setState({
input: value,
categoryFacets: null,
totalCount: null,
recordset: null,
error: null,
pageSize: null,
});
} else {
this.setState({ input: value }); this.setState({ input: value });
}
this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0); this.context.doSearch({
target: value,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
maxCount: this.state.pageSize ?? 20,
onUpdate: (result) => this.setState(result),
});
}; };
fetchResults = async () => { fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams); const { target, filter, offset } = queryFromParams(this.props.searchParams);
const trimmed = target.trim(); const trimmed = target.trim();
const { pageSize } = this.state; const { pageSize } = this.state;
if (trimmed.length < 3) {
const { categoryFacets, totalCount, recordset, error, pageSize: ps } = this.state;
if (
categoryFacets != null
|| totalCount != null
|| recordset != null
|| error != null
|| ps != null
) {
this.setState({
categoryFacets: null,
totalCount: null,
recordset: null,
error: null,
pageSize: null,
});
}
return;
}
if (pageSize == null) return; if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset }); writeArtikelQuery({ target, filter, offset });
try { // Delegate fetch + debounce to the global StorageManager.
const data = await article.search({ // The callback pushes results into local component state.
this.context.doSearch({
target: trimmed, target: trimmed,
filter: filter || undefined, filter: filter || undefined,
offset, offset,
maxCount: pageSize, maxCount: pageSize,
onUpdate: (result) => this.setState(result),
}); });
this.setState({
categoryFacets: data.categories ?? {},
totalCount: data.length ?? 0,
recordset: data.recordset ?? [],
error: null,
});
} catch (err) {
this.setState({
categoryFacets: null,
totalCount: null,
recordset: null,
error: backendErrorFrom(err),
});
}
}; };
render() { render() {
@@ -286,7 +253,7 @@ class ArtikelSearchPage extends Component {
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 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 >= 3; const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
const rows = recordset ?? []; const rows = recordset ?? [];
const facetEntries = categoryFacets const facetEntries = categoryFacets
@@ -339,12 +306,6 @@ class ArtikelSearchPage extends Component {
/> />
</Box> </Box>
{input.trim().length < 3 && (
<Typography color="text.secondary">
Mindestens 3 Zeichen eingeben — mehrere Wörter werden mit UND verknüpft.
</Typography>
)}
{error && ( {error && (
<BackendError <BackendError
error={error} error={error}
@@ -420,7 +381,7 @@ class ArtikelSearchPage extends Component {
</Box> </Box>
<Box ref={this.setListSlotRef}> <Box ref={this.setListSlotRef}>
{rows.length > 0 && pageSize != null && ( {rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding> <List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => ( {rows.map((row, index) => (
<ListItemButton <ListItemButton