Add category picker to article view with draft-based editing
- Add setCategories API method for PUT /api/article/:sku/categories - Create CategoryPickerDialog component for selecting categories - Add CategoryPills component with add/remove functionality - Category changes are now draft-based (like name editing) - Save button commits both name and category changes together - Delete icons hidden when only one category remains - Plus button only visible in edit mode Implements category assignment flow similar to name editing pattern.
This commit is contained in:
@@ -50,6 +50,13 @@ export default function ArticleView({
|
||||
const handleArticleUpdated = (next) => {
|
||||
onArticleUpdated?.(next);
|
||||
syncCategoryListPhoto(next);
|
||||
dispatch(
|
||||
patchArticleInLists({
|
||||
sku: next.sku,
|
||||
name: next.name,
|
||||
categoryIds: next.categoryIds,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleArticleDeleted = () => {
|
||||
@@ -71,7 +78,7 @@ export default function ArticleView({
|
||||
article={articleData}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
onArticleUpdated={onArticleUpdated}
|
||||
onArticleUpdated={handleArticleUpdated}
|
||||
/>
|
||||
|
||||
<ArticlePhotoTile
|
||||
|
||||
158
src/components/article/CategoryPickerDialog.jsx
Normal file
158
src/components/article/CategoryPickerDialog.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||
import Box from '@mui/material/Box';
|
||||
import { getCategoryChildren, categoryDisplayName } from '../../store/categorySlice';
|
||||
function CategoryNode({
|
||||
categories,
|
||||
categoryId,
|
||||
rootCategoryId,
|
||||
selectedId,
|
||||
onSelect,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
depth = 0,
|
||||
}) {
|
||||
const node = categories[String(categoryId)];
|
||||
if (!node) return null;
|
||||
|
||||
const children = getCategoryChildren(categories, categoryId);
|
||||
const hasChildren = children.length > 0;
|
||||
const isSelected = selectedId != null && String(selectedId) === String(categoryId);
|
||||
const isExpanded = expandedIds.has(categoryId);
|
||||
const label = categoryDisplayName(node.name, node.id, rootCategoryId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItemButton
|
||||
selected={isSelected}
|
||||
onClick={() => onSelect(categoryId)}
|
||||
sx={{ pl: 1 + depth * 1.5 }}
|
||||
>
|
||||
<Box sx={{ minWidth: 24, display: 'flex', alignItems: 'center' }}>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <FolderOpenIcon fontSize="small" /> : <FolderIcon fontSize="small" />
|
||||
) : null}
|
||||
</Box>
|
||||
<ListItemText primary={label} />
|
||||
{isSelected && <CheckIcon fontSize="small" color="primary" sx={{ ml: 1 }} />}
|
||||
{hasChildren && (
|
||||
isExpanded ? (
|
||||
<ExpandLess onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, false); }} />
|
||||
) : (
|
||||
<ExpandMore onClick={(e) => { e.stopPropagation(); onToggleExpand(categoryId, true); }} />
|
||||
)
|
||||
)}
|
||||
</ListItemButton>
|
||||
{hasChildren && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List disablePadding>
|
||||
{children.map((child) => (
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
categories={categories}
|
||||
categoryId={child.id}
|
||||
rootCategoryId={rootCategoryId}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds) {
|
||||
const ids = new Set();
|
||||
if (!categories || !rootCategoryId) return ids;
|
||||
for (const catId of currentCategoryIds ?? []) {
|
||||
let id = catId;
|
||||
while (id != null) {
|
||||
const node = categories[String(id)];
|
||||
if (!node) break;
|
||||
ids.add(id);
|
||||
if (node.parentId == null || node.parentId === 0) break;
|
||||
id = node.parentId;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export default function CategoryPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
categories,
|
||||
rootCategoryId,
|
||||
currentCategoryIds,
|
||||
onSelect,
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [expandedIds, setExpandedIds] = useState(
|
||||
() => buildDefaultExpanded(categories, rootCategoryId, currentCategoryIds),
|
||||
);
|
||||
|
||||
const handleToggleExpand = (id, expand) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expand) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedId == null) return;
|
||||
const categoryIds = currentCategoryIds.filter((id) => String(id) !== String(selectedId));
|
||||
categoryIds.push(selectedId);
|
||||
onSelect?.(categoryIds);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Kategorie zuweisen</DialogTitle>
|
||||
<DialogContent dividers sx={{ p: 0 }}>
|
||||
{categories && rootCategoryId != null ? (
|
||||
<List dense disablePadding>
|
||||
<CategoryNode
|
||||
categories={categories}
|
||||
categoryId={rootCategoryId}
|
||||
rootCategoryId={rootCategoryId}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ p: 2, textAlign: 'center', color: 'text.secondary' }}>
|
||||
Lade Kategorien…
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Abbrechen</Button>
|
||||
<Button onClick={handleConfirm} disabled={selectedId == null} variant="contained">
|
||||
Zuweisen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import SingleLineTextEdit from '../../SingleLineTextEdit';
|
||||
import { article } from '../../../api';
|
||||
import { requireObject } from '../../../utils/apiShape';
|
||||
import { PANEL_TILE_KEYS } from '../../../theme';
|
||||
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
|
||||
import { Box, Chip } from '@mui/material';
|
||||
import { Box, Chip, IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CategoryPickerDialog from '../CategoryPickerDialog';
|
||||
|
||||
const articleNameTextSx = {
|
||||
fontWeight: 700,
|
||||
@@ -42,12 +45,18 @@ const pillsContainerSx = {
|
||||
mt: 'auto',
|
||||
};
|
||||
|
||||
function nameDraftDirty(draft, saved) {
|
||||
return draft.name.trim() !== String(saved.name ?? '').trim();
|
||||
function draftDirty(draft, saved) {
|
||||
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
|
||||
const a = draft.categoryIds ?? [];
|
||||
const b = saved.categoryIds ?? [];
|
||||
const catDiff =
|
||||
a.length !== b.length ||
|
||||
[...a].map(String).sort().join(',') !== [...b].map(String).sort().join(',');
|
||||
return nameDiff || catDiff;
|
||||
}
|
||||
|
||||
function nameDraftValid(draft) {
|
||||
return draft.name.trim().length > 0;
|
||||
function draftValid(draft) {
|
||||
return draft.name.trim().length > 0 && (draft.categoryIds?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
const headerTileSx = {
|
||||
@@ -61,19 +70,73 @@ function buildCategoryLabel(categories, categoryId, rootCategoryId) {
|
||||
return crumbs.map((c) => c.name).join(' | ');
|
||||
}
|
||||
|
||||
function CategoryPills({
|
||||
categoryLabels,
|
||||
categoryIds,
|
||||
categories,
|
||||
rootCategoryId,
|
||||
sku,
|
||||
editMode,
|
||||
onCategoryIdsChange,
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const handleRemoveCategory = (removeId) => {
|
||||
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
|
||||
onCategoryIdsChange?.(remaining);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={pillsContainerSx}>
|
||||
{categoryLabels.map(({ id, label }) => (
|
||||
<Chip
|
||||
key={id}
|
||||
label={label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onDelete={editMode && categoryIds.length > 1 ? () => handleRemoveCategory(id) : undefined}
|
||||
/>
|
||||
))}
|
||||
{editMode && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
sx={{ ml: 0.5, width: 24, height: 24 }}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<CategoryPickerDialog
|
||||
key={sku}
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
currentCategoryIds={categoryIds}
|
||||
onSelect={onCategoryIdsChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArticleViewName({ article: articleData, categories, rootCategoryId, onArticleUpdated }) {
|
||||
const handleSave = async (draft) => {
|
||||
const name = draft.name.trim();
|
||||
const res = await article.update(articleData.sku, { name });
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
const categoryIds = draft.categoryIds ?? [];
|
||||
|
||||
const [nameRes] = await Promise.all([
|
||||
article.update(articleData.sku, { name }),
|
||||
article.setCategories(articleData.sku, categoryIds),
|
||||
]);
|
||||
|
||||
requireObject(nameRes, 'PATCH /api/article/:sku');
|
||||
requireObject(nameRes.article, 'article');
|
||||
onArticleUpdated?.(nameRes.article);
|
||||
};
|
||||
|
||||
const categoryIds = articleData.categoryIds ?? [];
|
||||
const categoryLabels = categoryIds
|
||||
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
|
||||
.filter((c) => c.label);
|
||||
const savedCategoryIds = articleData.categoryIds ?? [];
|
||||
|
||||
return (
|
||||
<EditablePanelTile
|
||||
@@ -81,50 +144,65 @@ export default function ArticleViewName({ article: articleData, categories, root
|
||||
tileKey={PANEL_TILE_KEYS.name}
|
||||
minHeight={0}
|
||||
sx={headerTileSx}
|
||||
value={{ name: articleData.name ?? '' }}
|
||||
value={{ name: articleData.name ?? '', categoryIds: [...savedCategoryIds] }}
|
||||
onSave={handleSave}
|
||||
isDirty={nameDraftDirty}
|
||||
canSave={nameDraftValid}
|
||||
renderDisplay={({ name }) => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<SingleLineTextEdit
|
||||
label="Name"
|
||||
value={name}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
sx={articleNameFieldHiddenSx}
|
||||
isDirty={draftDirty}
|
||||
canSave={draftValid}
|
||||
renderDisplay={({ name, categoryIds }) => {
|
||||
const labels = (categoryIds ?? [])
|
||||
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
|
||||
.filter((c) => c.label);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<SingleLineTextEdit
|
||||
label="Name"
|
||||
value={name}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
sx={articleNameFieldHiddenSx}
|
||||
/>
|
||||
</Box>
|
||||
{labels.length > 0 && (
|
||||
<CategoryPills
|
||||
categoryLabels={labels}
|
||||
categoryIds={categoryIds}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
sku={articleData.sku}
|
||||
editMode={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderEdit={(draft, onChange, inputRef) => {
|
||||
const labels = (draft.categoryIds ?? [])
|
||||
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
|
||||
.filter((c) => c.label);
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<SingleLineTextEdit
|
||||
inputRef={inputRef}
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
onChange={(name) => onChange({ ...draft, name })}
|
||||
sx={articleNameFieldSx}
|
||||
/>
|
||||
</Box>
|
||||
<CategoryPills
|
||||
categoryLabels={labels}
|
||||
categoryIds={draft.categoryIds}
|
||||
categories={categories}
|
||||
rootCategoryId={rootCategoryId}
|
||||
sku={articleData.sku}
|
||||
editMode={true}
|
||||
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
|
||||
/>
|
||||
</Box>
|
||||
{categoryLabels.length > 0 && (
|
||||
<Box sx={pillsContainerSx}>
|
||||
{categoryLabels.map(({ id, label }) => (
|
||||
<Chip key={id} label={label} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, alignSelf: 'stretch' }}>
|
||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<SingleLineTextEdit
|
||||
inputRef={inputRef}
|
||||
label="Name"
|
||||
value={draft.name}
|
||||
onChange={(name) => onChange({ name })}
|
||||
sx={articleNameFieldSx}
|
||||
/>
|
||||
</Box>
|
||||
{categoryLabels.length > 0 && (
|
||||
<Box sx={pillsContainerSx}>
|
||||
{categoryLabels.map(({ id, label }) => (
|
||||
<Chip key={id} label={label} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user