From 8f371749c60b7f74f531ffa19a67a9b79ee3a2dd Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 23:43:57 +0200 Subject: [PATCH] u --- src/api/storageManager.jsx | 83 ++++++++++++++ .../article/boxes/ArticleViewName.jsx | 2 +- src/main.jsx | 5 +- src/pages/artikel/index.jsx | 107 ++++++------------ 4 files changed, 122 insertions(+), 75 deletions(-) create mode 100644 src/api/storageManager.jsx diff --git a/src/api/storageManager.jsx b/src/api/storageManager.jsx new file mode 100644 index 0000000..77195ce --- /dev/null +++ b/src/api/storageManager.jsx @@ -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 ( + + {children} + + ); +} + +/** + * 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 '); + } + return ctx; +} + +export default StorageManagerContext; diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index 0a25557..23452b0 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -23,7 +23,7 @@ const articleNameFieldSx = { const articleNameFieldHiddenSx = { ...articleNameFieldSx, - pointerEvents: 'none', + userSelect: 'text', '& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent', }, diff --git a/src/main.jsx b/src/main.jsx index d1a2f83..98a3bc7 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,6 +4,7 @@ import { ThemeProvider } from '@mui/material/styles'; import { Provider } from 'react-redux'; import { SnackbarProvider } from 'notistack'; import AuthBootstrap from './components/AuthBootstrap'; +import { StorageManagerProvider } from './api/storageManager'; import App from './App'; import { store } from './store'; import { getActiveTheme } from './theme'; @@ -33,7 +34,9 @@ createRoot(document.getElementById('root')).render( anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - + + + diff --git a/src/pages/artikel/index.jsx b/src/pages/artikel/index.jsx index eb79250..a914fae 100644 --- a/src/pages/artikel/index.jsx +++ b/src/pages/artikel/index.jsx @@ -9,13 +9,11 @@ import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; -import { article } from '../../api'; +import { StorageManagerContext } from '../../api/storageManager'; import BackendError from '../../components/BackendError'; -import { backendErrorFrom } from '../../utils/backendError'; import { formatPrice } from '../../utils/price'; import { buildSearchUrl, - clearArtikelQuery, queryFromParams, readArtikelQuery, writeArtikelQuery, @@ -82,6 +80,8 @@ function initialSearchState(searchParams) { } class ArtikelSearchPage extends Component { + static contextType = StorageManagerContext; + state = initialSearchState(this.props.searchParams); listSlotRef = { current: null }; rowMeasureRef = { current: null }; @@ -145,7 +145,7 @@ class ArtikelSearchPage extends Component { this.setState({ pageSize: next }, () => { 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() { document.addEventListener('mousedown', preserveSearchFocus, true); 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() { @@ -206,78 +216,35 @@ class ArtikelSearchPage extends Component { handleInputChange = (event) => { const value = event.target.value; - const belowMin = value.trim().length < 3; + this.setState({ input: value }); - 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.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 trimmed = target.trim(); 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; writeArtikelQuery({ target, filter, offset }); - try { - const data = await article.search({ - target: trimmed, - filter: filter || undefined, - offset, - maxCount: pageSize, - }); - - 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), - }); - } + // Delegate fetch + debounce to the global StorageManager. + // The callback pushes results into local component state. + this.context.doSearch({ + target: trimmed, + filter: filter || undefined, + offset, + maxCount: pageSize, + onUpdate: (result) => this.setState(result), + }); }; render() { @@ -286,7 +253,7 @@ class ArtikelSearchPage extends Component { const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); 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 >= 3; + const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0; const rows = recordset ?? []; const facetEntries = categoryFacets @@ -339,12 +306,6 @@ class ArtikelSearchPage extends Component { /> - {input.trim().length < 3 && ( - - Mindestens 3 Zeichen eingeben — mehrere Wörter werden mit UND verknüpft. - - )} - {error && ( - {rows.length > 0 && pageSize != null && ( + {rows.length > 0 && ( {rows.map((row, index) => (