|
|
|
|
@@ -8,7 +8,6 @@ import DialogContent from '@mui/material/DialogContent';
|
|
|
|
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
|
|
|
import IconButton from '@mui/material/IconButton';
|
|
|
|
|
import InputAdornment from '@mui/material/InputAdornment';
|
|
|
|
|
import List from '@mui/material/List';
|
|
|
|
|
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
|
|
|
|
import ListItemButton from '@mui/material/ListItemButton';
|
|
|
|
|
import ListItemText from '@mui/material/ListItemText';
|
|
|
|
|
@@ -27,7 +26,6 @@ import ArticlePhoto from './ArticlePhoto';
|
|
|
|
|
import { BrandLabel } from '../brand';
|
|
|
|
|
import { formatPrice } from '../../utils/price';
|
|
|
|
|
import { formatStock } from './searchFormat';
|
|
|
|
|
import ResultsPagination from './ResultsPagination';
|
|
|
|
|
import { BORDER, SCRIM_LABEL } from '../../colors';
|
|
|
|
|
|
|
|
|
|
const searchResultRowSx = {
|
|
|
|
|
@@ -90,385 +88,25 @@ function SearchRowMeasureSample() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Unified article search component based on articleSearchAutomat / articleIndexAutomat.
|
|
|
|
|
*
|
|
|
|
|
* Supported Modes:
|
|
|
|
|
* 1. 'dialog' (default): Inline trigger field that opens a spacious modal dialog.
|
|
|
|
|
* 2. 'list': Standalone search input with rich results list and dynamic FillSpace pagination.
|
|
|
|
|
* 3. 'autocomplete': Standard Autocomplete with drop-down popper.
|
|
|
|
|
*
|
|
|
|
|
* @param {object} props
|
|
|
|
|
* @param {'dialog'|'list'|'autocomplete'} [props.mode='dialog']
|
|
|
|
|
* @param {string} [props.placeholder='Artikel suchen…']
|
|
|
|
|
* @param {string} [props.label]
|
|
|
|
|
* @param {object|null} [props.value] Currently selected article.
|
|
|
|
|
* @param {Function} [props.onSelect] Callback when article selected: (article) => void.
|
|
|
|
|
* @param {Function} [props.onChange] Alternative callback for selection.
|
|
|
|
|
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
|
|
|
|
|
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
|
|
|
|
|
* @param {Array<string>} [props.categories=[]] Category IDs to filter by.
|
|
|
|
|
* @param {number} [props.pageSize] Initial results per page (measured dynamically by FillSpace).
|
|
|
|
|
* @param {boolean} [props.clearOnSelect=false]
|
|
|
|
|
* @param {boolean} [props.disabled=false]
|
|
|
|
|
* @param {boolean} [props.autoFocus=false]
|
|
|
|
|
* @param {'small'|'medium'} [props.size='small']
|
|
|
|
|
* @param {object} [props.sx]
|
|
|
|
|
* @param {boolean} [props.open] For controlled dialog: whether dialog is open.
|
|
|
|
|
* @param {Function} [props.onClose] For controlled dialog: close callback.
|
|
|
|
|
* @param {string} [props.title] Dialog title.
|
|
|
|
|
* @param {string} [props.actionLabel='Auswählen'] Label on selection button.
|
|
|
|
|
* Ambient context for the `renderItem` row. FillSpace renders rows as a bare
|
|
|
|
|
* element and cannot pass page callbacks, so `renderListContent` publishes the
|
|
|
|
|
* current selection handler / busy state here on every render.
|
|
|
|
|
*/
|
|
|
|
|
export default class ArticleSearch extends PureComponent {
|
|
|
|
|
headerRef = createRef();
|
|
|
|
|
paginationRef = createRef();
|
|
|
|
|
listContainerRef = createRef();
|
|
|
|
|
contentContainerRef = createRef();
|
|
|
|
|
fillSpaceRef = createRef();
|
|
|
|
|
const searchRowsContext = { busySku: null, actionLabel: 'Auswählen', onSelect: null };
|
|
|
|
|
|
|
|
|
|
constructor(props) {
|
|
|
|
|
super(props);
|
|
|
|
|
const initialQuery = props.initialQuery ?? '';
|
|
|
|
|
const pageSize = props.pageSize ?? 8;
|
|
|
|
|
this.state = {
|
|
|
|
|
query: initialQuery,
|
|
|
|
|
offset: 0,
|
|
|
|
|
pageSize,
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
pageCount: 1,
|
|
|
|
|
currentPage: 1,
|
|
|
|
|
searchKey: 0,
|
|
|
|
|
results: [],
|
|
|
|
|
indexStatus: articleIndexAutomat.state.status,
|
|
|
|
|
busySku: props.busySku || null,
|
|
|
|
|
error: null,
|
|
|
|
|
internalOpen: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* 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 SearchResultRow extends PureComponent {
|
|
|
|
|
render() {
|
|
|
|
|
const { item: article } = this.props;
|
|
|
|
|
const { busySku, actionLabel, onSelect } = searchRowsContext;
|
|
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
|
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
|
|
|
|
|
indexStatus: state.status,
|
|
|
|
|
}));
|
|
|
|
|
// Data-free probe (`item` undefined): the fixed-height measure sample.
|
|
|
|
|
if (!article) return <SearchRowMeasureSample />;
|
|
|
|
|
|
|
|
|
|
if (articleIndexAutomat.state.status === 'idle') {
|
|
|
|
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.runQuery(this.state.query, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
|
|
|
if (
|
|
|
|
|
this.state.indexStatus !== prevState.indexStatus ||
|
|
|
|
|
this.props.excludeSkus !== prevProps.excludeSkus ||
|
|
|
|
|
this.props.allowParent !== prevProps.allowParent ||
|
|
|
|
|
this.props.categories !== prevProps.categories
|
|
|
|
|
) {
|
|
|
|
|
this.runQuery(this.state.query, this.state.offset);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.props.busySku !== prevProps.busySku) {
|
|
|
|
|
this.setState({ busySku: this.props.busySku });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
|
|
|
|
|
this.setState({ query: this.props.initialQuery, offset: 0 });
|
|
|
|
|
this.runQuery(this.props.initialQuery, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
|
this.unsubIndex?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleMeasurement = (count) => {
|
|
|
|
|
if (!count || count <= 0 || count === this.state.pageSize) return;
|
|
|
|
|
this.setState({ pageSize: count });
|
|
|
|
|
this.runQuery(this.state.query, this.state.offset, count);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runQuery = (queryText, newOffset = 0, customPageSize) => {
|
|
|
|
|
const {
|
|
|
|
|
excludeSkus = [],
|
|
|
|
|
allowParent = false,
|
|
|
|
|
categories = [],
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
const pageSize = customPageSize ?? this.state.pageSize;
|
|
|
|
|
|
|
|
|
|
const res = searchArticlesIndex(queryText, {
|
|
|
|
|
excludeSkus,
|
|
|
|
|
allowParent,
|
|
|
|
|
categories,
|
|
|
|
|
offset: newOffset,
|
|
|
|
|
pageSize,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
|
results: Array.isArray(res) ? res : (res.recordset ?? []),
|
|
|
|
|
totalCount: res.totalCount ?? res.length,
|
|
|
|
|
pageCount: res.pageCount ?? 1,
|
|
|
|
|
currentPage: res.currentPage ?? 1,
|
|
|
|
|
offset: newOffset,
|
|
|
|
|
query: queryText,
|
|
|
|
|
pageSize,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handlePageChange = (_e, page) => {
|
|
|
|
|
const nextOffset = (page - 1) * this.state.pageSize;
|
|
|
|
|
this.runQuery(this.state.query, nextOffset);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleOpenDialog = () => {
|
|
|
|
|
const initialQuery = this.props.initialQuery ?? '';
|
|
|
|
|
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
|
|
|
|
|
this.runQuery(initialQuery, 0);
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this.fillSpaceRef.current?.measure();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleCloseDialog = () => {
|
|
|
|
|
this.setState({ internalOpen: false });
|
|
|
|
|
this.props.onClose?.();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleSelectArticle = (article) => {
|
|
|
|
|
const { onSelect, onChange, clearOnSelect } = this.props;
|
|
|
|
|
|
|
|
|
|
if (article) {
|
|
|
|
|
this.setState({ internalOpen: false });
|
|
|
|
|
this.props.onClose?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (clearOnSelect) {
|
|
|
|
|
this.setState((prev) => ({
|
|
|
|
|
query: '',
|
|
|
|
|
offset: 0,
|
|
|
|
|
searchKey: prev.searchKey + 1,
|
|
|
|
|
}));
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onSelect?.(article);
|
|
|
|
|
onChange?.(article);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
renderTrigger() {
|
|
|
|
|
const {
|
|
|
|
|
placeholder = '',
|
|
|
|
|
value = null,
|
|
|
|
|
disabled = false,
|
|
|
|
|
size = 'small',
|
|
|
|
|
sx,
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
const displayValue = value
|
|
|
|
|
? typeof value === 'string'
|
|
|
|
|
? value
|
|
|
|
|
: (value.sku ? `${value.sku} - ${value.name || ''}` : (value.name || ''))
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TextInput
|
|
|
|
|
fullWidth
|
|
|
|
|
size={size}
|
|
|
|
|
placeholder={placeholder || ''}
|
|
|
|
|
value={displayValue}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!disabled) this.handleOpenDialog();
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!disabled) this.handleOpenDialog();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
readOnly
|
|
|
|
|
slotProps={{
|
|
|
|
|
input: {
|
|
|
|
|
notched: false,
|
|
|
|
|
readOnly: true,
|
|
|
|
|
sx: {
|
|
|
|
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
|
|
|
|
display: 'none',
|
|
|
|
|
},
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
'& input': {
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
startAdornment: (
|
|
|
|
|
<InputAdornment position="start">
|
|
|
|
|
<SearchIcon fontSize="small" color="action" />
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
),
|
|
|
|
|
endAdornment: value ? (
|
|
|
|
|
<InputAdornment position="end">
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Auswahl aufheben"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
this.handleSelectArticle(null);
|
|
|
|
|
}}
|
|
|
|
|
edge="end"
|
|
|
|
|
>
|
|
|
|
|
<CloseIcon sx={{ fontSize: 16 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
) : null,
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
sx={{
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
...sx,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderListContent() {
|
|
|
|
|
const {
|
|
|
|
|
placeholder = '',
|
|
|
|
|
autoFocus = true,
|
|
|
|
|
actionLabel = 'Auswählen',
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
query,
|
|
|
|
|
results,
|
|
|
|
|
busySku,
|
|
|
|
|
error,
|
|
|
|
|
offset,
|
|
|
|
|
pageSize,
|
|
|
|
|
totalCount,
|
|
|
|
|
pageCount,
|
|
|
|
|
currentPage,
|
|
|
|
|
} = this.state;
|
|
|
|
|
const loading = this.state.indexStatus === 'loading';
|
|
|
|
|
const showPagination = totalCount > pageSize;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
ref={this.listContainerRef}
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
gap: 1.5,
|
|
|
|
|
flex: 1,
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
height: '100%',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{error && (
|
|
|
|
|
<Alert severity="error" sx={{ mb: 1, flexShrink: 0 }}>
|
|
|
|
|
{error}
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Box ref={this.headerRef} sx={{ flexShrink: 0 }}>
|
|
|
|
|
<TextInput
|
|
|
|
|
autoFocus={autoFocus}
|
|
|
|
|
fullWidth
|
|
|
|
|
size="small"
|
|
|
|
|
readOnly={false}
|
|
|
|
|
placeholder={placeholder || ''}
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
this.setState({ query: val, offset: 0 });
|
|
|
|
|
this.runQuery(val, 0);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Escape' && query) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.setState({ query: '', offset: 0 });
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
} else if (e.key === 'Enter' && results.length > 0) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handleSelectArticle(results[0]);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={Boolean(busySku)}
|
|
|
|
|
slotProps={{
|
|
|
|
|
input: {
|
|
|
|
|
notched: false,
|
|
|
|
|
sx: {
|
|
|
|
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
|
|
|
|
display: 'none',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
startAdornment: (
|
|
|
|
|
<InputAdornment position="start">
|
|
|
|
|
<SearchIcon fontSize="small" color="action" />
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
),
|
|
|
|
|
endAdornment: query ? (
|
|
|
|
|
<InputAdornment position="end">
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Eingabe leeren"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
this.setState({ query: '', offset: 0 });
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
}}
|
|
|
|
|
edge="end"
|
|
|
|
|
>
|
|
|
|
|
<CloseIcon sx={{ fontSize: 16 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
) : null,
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
<Box ref={this.paginationRef} sx={{ px: 0.5, flexShrink: 0 }}>
|
|
|
|
|
<ResultsPagination
|
|
|
|
|
invisible={!showPagination}
|
|
|
|
|
offset={showPagination ? offset : 0}
|
|
|
|
|
totalCount={showPagination ? totalCount : pageSize + 1}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
pageCount={showPagination ? pageCount : 1}
|
|
|
|
|
currentPage={showPagination ? currentPage : 1}
|
|
|
|
|
onPageChange={this.handlePageChange}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
<FillSpace
|
|
|
|
|
ref={this.fillSpaceRef}
|
|
|
|
|
aboveRef={[this.headerRef, this.paginationRef]}
|
|
|
|
|
containerRef={this.contentContainerRef.current ? this.contentContainerRef : this.listContainerRef}
|
|
|
|
|
onMeasurement={this.handleMeasurement}
|
|
|
|
|
measureElement={<SearchRowMeasureSample />}
|
|
|
|
|
minHeight={180}
|
|
|
|
|
sx={{ overflow: 'auto', flex: 1, minHeight: 0 }}
|
|
|
|
|
>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 6, gap: 1.5 }}>
|
|
|
|
|
<CircularProgress size={22} />
|
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
|
|
|
Artikel werden geladen…
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
) : results.length === 0 ? null : (
|
|
|
|
|
<List
|
|
|
|
|
dense
|
|
|
|
|
disablePadding
|
|
|
|
|
sx={{
|
|
|
|
|
border: '1px solid',
|
|
|
|
|
borderColor: 'divider',
|
|
|
|
|
borderRadius: 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{results.map((article) => {
|
|
|
|
|
const photo = article.photos?.[0] || article.articlepic?.[0];
|
|
|
|
|
const fullHash = photo?.hash || photo?.fullHash;
|
|
|
|
|
const thumbHash = photo?.thumbhash || photo?.thumbHash;
|
|
|
|
|
@@ -481,10 +119,9 @@ export default class ArticleSearch extends PureComponent {
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ListItemButton
|
|
|
|
|
key={article.id || article.sku}
|
|
|
|
|
divider
|
|
|
|
|
disabled={Boolean(busySku)}
|
|
|
|
|
onClick={() => this.handleSelectArticle(article)}
|
|
|
|
|
onClick={() => onSelect?.(article)}
|
|
|
|
|
sx={searchResultRowSx}
|
|
|
|
|
>
|
|
|
|
|
<ListItemAvatar sx={{ minWidth: 48 }}>
|
|
|
|
|
@@ -612,7 +249,7 @@ export default class ArticleSearch extends PureComponent {
|
|
|
|
|
disabled={Boolean(busySku)}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
this.handleSelectArticle(article);
|
|
|
|
|
onSelect?.(article);
|
|
|
|
|
}}
|
|
|
|
|
sx={{
|
|
|
|
|
fontSize: '0.75rem',
|
|
|
|
|
@@ -627,10 +264,339 @@ export default class ArticleSearch extends PureComponent {
|
|
|
|
|
</Box>
|
|
|
|
|
</ListItemButton>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</List>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Unified article search component based on articleSearchAutomat / articleIndexAutomat.
|
|
|
|
|
*
|
|
|
|
|
* Supported Modes:
|
|
|
|
|
* 1. 'dialog' (default): Inline trigger field that opens a spacious modal dialog.
|
|
|
|
|
* 2. 'list': Standalone search input with rich results list and dynamic FillSpace pagination.
|
|
|
|
|
* 3. 'autocomplete': Standard Autocomplete with drop-down popper.
|
|
|
|
|
*
|
|
|
|
|
* @param {object} props
|
|
|
|
|
* @param {'dialog'|'list'|'autocomplete'} [props.mode='dialog']
|
|
|
|
|
* @param {string} [props.placeholder='Artikel suchen…']
|
|
|
|
|
* @param {string} [props.label]
|
|
|
|
|
* @param {object|null} [props.value] Currently selected article.
|
|
|
|
|
* @param {Function} [props.onSelect] Callback when article selected: (article) => void.
|
|
|
|
|
* @param {Function} [props.onChange] Alternative callback for selection.
|
|
|
|
|
* @param {Array<string>} [props.excludeSkus=[]] SKUs to exclude.
|
|
|
|
|
* @param {boolean} [props.allowParent=false] Whether parent articles are selectable.
|
|
|
|
|
* @param {Array<string>} [props.categories=[]] Category IDs to filter by.
|
|
|
|
|
* @param {boolean} [props.clearOnSelect=false]
|
|
|
|
|
* @param {boolean} [props.disabled=false]
|
|
|
|
|
* @param {boolean} [props.autoFocus=false]
|
|
|
|
|
* @param {'small'|'medium'} [props.size='small']
|
|
|
|
|
* @param {object} [props.sx]
|
|
|
|
|
* @param {boolean} [props.open] For controlled dialog: whether dialog is open.
|
|
|
|
|
* @param {Function} [props.onClose] For controlled dialog: close callback.
|
|
|
|
|
* @param {string} [props.title] Dialog title.
|
|
|
|
|
* @param {string} [props.actionLabel='Auswählen'] Label on selection button.
|
|
|
|
|
*/
|
|
|
|
|
export default class ArticleSearch extends PureComponent {
|
|
|
|
|
headerRef = createRef();
|
|
|
|
|
listContainerRef = createRef();
|
|
|
|
|
contentContainerRef = createRef();
|
|
|
|
|
fillSpaceRef = createRef();
|
|
|
|
|
|
|
|
|
|
constructor(props) {
|
|
|
|
|
super(props);
|
|
|
|
|
const initialQuery = props.initialQuery ?? '';
|
|
|
|
|
this.state = {
|
|
|
|
|
query: initialQuery,
|
|
|
|
|
offset: 0,
|
|
|
|
|
searchKey: 0,
|
|
|
|
|
results: [],
|
|
|
|
|
indexStatus: articleIndexAutomat.state.status,
|
|
|
|
|
busySku: props.busySku || null,
|
|
|
|
|
error: null,
|
|
|
|
|
internalOpen: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
|
this.unsubIndex = articleIndexAutomat.subscribe(this, (state) => ({
|
|
|
|
|
indexStatus: state.status,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (articleIndexAutomat.state.status === 'idle') {
|
|
|
|
|
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.runQuery(this.state.query, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
|
|
|
if (
|
|
|
|
|
this.state.indexStatus !== prevState.indexStatus ||
|
|
|
|
|
this.props.excludeSkus !== prevProps.excludeSkus ||
|
|
|
|
|
this.props.allowParent !== prevProps.allowParent ||
|
|
|
|
|
this.props.categories !== prevProps.categories
|
|
|
|
|
) {
|
|
|
|
|
this.runQuery(this.state.query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.props.busySku !== prevProps.busySku) {
|
|
|
|
|
this.setState({ busySku: this.props.busySku });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) {
|
|
|
|
|
this.setState({ query: this.props.initialQuery, offset: 0 });
|
|
|
|
|
this.runQuery(this.props.initialQuery);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
|
this.unsubIndex?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
runQuery = (queryText) => {
|
|
|
|
|
const {
|
|
|
|
|
excludeSkus = [],
|
|
|
|
|
allowParent = false,
|
|
|
|
|
categories = [],
|
|
|
|
|
mode,
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
// FillSpace paginates `results` itself, so the list/dialog modes load the
|
|
|
|
|
// full match set; the autocomplete popper keeps a small option cap.
|
|
|
|
|
const res = searchArticlesIndex(queryText, {
|
|
|
|
|
excludeSkus,
|
|
|
|
|
allowParent,
|
|
|
|
|
categories,
|
|
|
|
|
offset: 0,
|
|
|
|
|
limit: mode === 'autocomplete' ? 20 : Infinity,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
|
results: Array.isArray(res) ? res : (res.recordset ?? []),
|
|
|
|
|
offset: 0,
|
|
|
|
|
query: queryText,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleOpenDialog = () => {
|
|
|
|
|
const initialQuery = this.props.initialQuery ?? '';
|
|
|
|
|
this.setState({ internalOpen: true, query: initialQuery, offset: 0 });
|
|
|
|
|
this.runQuery(initialQuery);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleCloseDialog = () => {
|
|
|
|
|
this.setState({ internalOpen: false });
|
|
|
|
|
this.props.onClose?.();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleSelectArticle = (article) => {
|
|
|
|
|
const { onSelect, onChange, clearOnSelect } = this.props;
|
|
|
|
|
|
|
|
|
|
if (article) {
|
|
|
|
|
this.setState({ internalOpen: false });
|
|
|
|
|
this.props.onClose?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (clearOnSelect) {
|
|
|
|
|
this.setState((prev) => ({
|
|
|
|
|
query: '',
|
|
|
|
|
offset: 0,
|
|
|
|
|
searchKey: prev.searchKey + 1,
|
|
|
|
|
}));
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onSelect?.(article);
|
|
|
|
|
onChange?.(article);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
renderTrigger() {
|
|
|
|
|
const {
|
|
|
|
|
placeholder = '',
|
|
|
|
|
value = null,
|
|
|
|
|
disabled = false,
|
|
|
|
|
size = 'small',
|
|
|
|
|
sx,
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
const displayValue = value
|
|
|
|
|
? typeof value === 'string'
|
|
|
|
|
? value
|
|
|
|
|
: (value.sku ? `${value.sku} - ${value.name || ''}` : (value.name || ''))
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TextInput
|
|
|
|
|
fullWidth
|
|
|
|
|
size={size}
|
|
|
|
|
placeholder={placeholder || ''}
|
|
|
|
|
value={displayValue}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!disabled) this.handleOpenDialog();
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!disabled) this.handleOpenDialog();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
readOnly
|
|
|
|
|
slotProps={{
|
|
|
|
|
input: {
|
|
|
|
|
notched: false,
|
|
|
|
|
readOnly: true,
|
|
|
|
|
sx: {
|
|
|
|
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
|
|
|
|
display: 'none',
|
|
|
|
|
},
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
'& input': {
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
startAdornment: (
|
|
|
|
|
<InputAdornment position="start">
|
|
|
|
|
<SearchIcon fontSize="small" color="action" />
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
),
|
|
|
|
|
endAdornment: value ? (
|
|
|
|
|
<InputAdornment position="end">
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Auswahl aufheben"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
this.handleSelectArticle(null);
|
|
|
|
|
}}
|
|
|
|
|
edge="end"
|
|
|
|
|
>
|
|
|
|
|
<CloseIcon sx={{ fontSize: 16 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
) : null,
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
sx={{
|
|
|
|
|
cursor: disabled ? 'default' : 'pointer',
|
|
|
|
|
...sx,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderListContent() {
|
|
|
|
|
const {
|
|
|
|
|
placeholder = '',
|
|
|
|
|
autoFocus = true,
|
|
|
|
|
actionLabel = 'Auswählen',
|
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
|
|
const { query, results, busySku, error } = this.state;
|
|
|
|
|
const loading = this.state.indexStatus === 'loading';
|
|
|
|
|
|
|
|
|
|
// Values the static `renderItem` row reads through the context.
|
|
|
|
|
searchRowsContext.busySku = busySku;
|
|
|
|
|
searchRowsContext.actionLabel = actionLabel;
|
|
|
|
|
searchRowsContext.onSelect = this.handleSelectArticle;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
ref={this.listContainerRef}
|
|
|
|
|
sx={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
gap: 1.5,
|
|
|
|
|
flex: 1,
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
height: '100%',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{error && (
|
|
|
|
|
<Alert severity="error" sx={{ mb: 1, flexShrink: 0 }}>
|
|
|
|
|
{error}
|
|
|
|
|
</Alert>
|
|
|
|
|
)}
|
|
|
|
|
</FillSpace>
|
|
|
|
|
|
|
|
|
|
<Box ref={this.headerRef} sx={{ flexShrink: 0 }}>
|
|
|
|
|
<TextInput
|
|
|
|
|
autoFocus={autoFocus}
|
|
|
|
|
fullWidth
|
|
|
|
|
size="small"
|
|
|
|
|
readOnly={false}
|
|
|
|
|
placeholder={placeholder || ''}
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const val = e.target.value;
|
|
|
|
|
this.setState({ query: val, offset: 0 });
|
|
|
|
|
this.runQuery(val, 0);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Escape' && query) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.setState({ query: '', offset: 0 });
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
} else if (e.key === 'Enter' && results.length > 0) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handleSelectArticle(results[0]);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
disabled={Boolean(busySku)}
|
|
|
|
|
slotProps={{
|
|
|
|
|
input: {
|
|
|
|
|
notched: false,
|
|
|
|
|
sx: {
|
|
|
|
|
'& .MuiOutlinedInput-notchedOutline legend': {
|
|
|
|
|
display: 'none',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
startAdornment: (
|
|
|
|
|
<InputAdornment position="start">
|
|
|
|
|
<SearchIcon fontSize="small" color="action" />
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
),
|
|
|
|
|
endAdornment: query ? (
|
|
|
|
|
<InputAdornment position="end">
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
aria-label="Eingabe leeren"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
this.setState({ query: '', offset: 0 });
|
|
|
|
|
this.runQuery('', 0);
|
|
|
|
|
}}
|
|
|
|
|
edge="end"
|
|
|
|
|
>
|
|
|
|
|
<CloseIcon sx={{ fontSize: 16 }} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</InputAdornment>
|
|
|
|
|
) : null,
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
<FillSpace
|
|
|
|
|
ref={this.fillSpaceRef}
|
|
|
|
|
aboveRef={this.headerRef}
|
|
|
|
|
containerRef={this.contentContainerRef}
|
|
|
|
|
items={results}
|
|
|
|
|
renderItem={SearchResultRow}
|
|
|
|
|
minPageSize={6}
|
|
|
|
|
minHeight={180}
|
|
|
|
|
sx={{ overflow: 'auto', flex: 1, minHeight: 0 }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{loading && results.length === 0 ? (
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', py: 3, gap: 1.5 }}>
|
|
|
|
|
<CircularProgress size={22} />
|
|
|
|
|
<Typography variant="body2" color="text.secondary">
|
|
|
|
|
Artikel werden geladen…
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
) : null}
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|