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:
seb
2026-08-03 08:57:32 +02:00
parent 26d7c4a021
commit 78be835388
4 changed files with 311 additions and 55 deletions

View File

@@ -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>
)}
);
}}
/>
);
}