u
This commit is contained in:
83
src/api/storageManager.jsx
Normal file
83
src/api/storageManager.jsx
Normal 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;
|
||||||
@@ -23,7 +23,7 @@ const articleNameFieldSx = {
|
|||||||
|
|
||||||
const articleNameFieldHiddenSx = {
|
const articleNameFieldHiddenSx = {
|
||||||
...articleNameFieldSx,
|
...articleNameFieldSx,
|
||||||
pointerEvents: 'none',
|
userSelect: 'text',
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
<App />
|
<StorageManagerProvider>
|
||||||
|
<App />
|
||||||
|
</StorageManagerProvider>
|
||||||
</AuthBootstrap>
|
</AuthBootstrap>
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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;
|
this.setState({ input: value });
|
||||||
|
|
||||||
if (belowMin) {
|
this.context.doSearch({
|
||||||
clearArtikelQuery();
|
target: value,
|
||||||
// Clear results in the same update that shortens the input — otherwise the
|
filter: this.props.searchParams.get('filter') || undefined,
|
||||||
// hint appears one paint before fetchResults (driven by URL) removes the list.
|
offset: 0,
|
||||||
this.setState({
|
maxCount: this.state.pageSize ?? 20,
|
||||||
input: value,
|
onUpdate: (result) => this.setState(result),
|
||||||
categoryFacets: null,
|
});
|
||||||
totalCount: null,
|
|
||||||
recordset: null,
|
|
||||||
error: null,
|
|
||||||
pageSize: null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({ input: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateParams(value, this.props.searchParams.get('filter') ?? '', 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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.
|
||||||
target: trimmed,
|
this.context.doSearch({
|
||||||
filter: filter || undefined,
|
target: trimmed,
|
||||||
offset,
|
filter: filter || undefined,
|
||||||
maxCount: pageSize,
|
offset,
|
||||||
});
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user