Files
dashfront/src/components/article/boxes/ArticleViewName.jsx

276 lines
7.3 KiB
JavaScript

import { Component } from 'react';
import { EditablePanelTile } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireObject } from '../../../utils/apiShape';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
import { Box, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog';
const articleNameTextSx = {
fontWeight: 700,
lineHeight: 1.2,
};
const articleNameFieldSx = {
'& .MuiInputBase-input': {
fontSize: '2.125rem',
...articleNameTextSx,
},
};
const articleNameFieldHiddenSx = {
...articleNameFieldSx,
userSelect: 'text',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiInputLabel-root': {
visibility: 'hidden',
},
};
const pillsContainerSx = {
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 'auto',
};
const columnSx = {
display: 'flex',
flexDirection: 'column',
flex: 1,
alignSelf: 'stretch',
};
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 draftValid(draft) {
return draft.name.trim().length > 0 && (draft.categoryIds?.length ?? 0) > 0;
}
const headerTileSx = {
minHeight: 0,
};
function buildCategoryLabel(categories, categoryId, rootCategoryId) {
if (!categories || categoryId == null) return '';
const crumbs = getCategoryBreadcrumbs(categories, categoryId, rootCategoryId);
if (crumbs.length === 0) return '';
return crumbs.map((c) => c.name).join(' | ');
}
function categoryLabels(categoryIds, categories, rootCategoryId) {
return (categoryIds ?? [])
.map((id) => ({ id, label: buildCategoryLabel(categories, id, rootCategoryId) }))
.filter((c) => c.label);
}
class CategoryPills extends Component {
constructor(props) {
super(props);
this.state = { pickerOpen: false };
}
handleRemoveCategory = (removeId) => {
const { categoryIds, onCategoryIdsChange } = this.props;
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
onCategoryIdsChange?.(remaining);
};
handleOpenPicker = () => {
this.setState({ pickerOpen: true });
};
handleClosePicker = () => {
this.setState({ pickerOpen: false });
};
render() {
const {
categoryLabels: labels,
categoryIds,
categories,
rootCategoryId,
sku,
editMode,
onCategoryIdsChange,
} = this.props;
const { pickerOpen } = this.state;
return (
<>
<Box sx={pillsContainerSx}>
{labels.map(({ id, label }) => (
<Chip
key={id}
label={label}
size="small"
variant="outlined"
onDelete={
editMode && categoryIds.length > 1
? () => this.handleRemoveCategory(id)
: undefined
}
/>
))}
{editMode ? (
<IconButton
size="small"
onClick={this.handleOpenPicker}
sx={{ ml: 0.5, width: 24, height: 24 }}
>
<AddIcon fontSize="small" />
</IconButton>
) : null}
</Box>
<CategoryPickerDialog
key={sku}
open={pickerOpen}
onClose={this.handleClosePicker}
categories={categories}
rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds}
onSelect={onCategoryIdsChange}
/>
</>
);
}
}
class NameDisplay extends Component {
render() {
const { name, categoryIds, categories, rootCategoryId, sku } = this.props;
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
return (
<Box sx={columnSx}>
<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={sku}
editMode={false}
/>
) : null}
</Box>
);
}
}
class NameEdit extends Component {
render() {
const {
draft,
onChange,
inputRef,
categories,
rootCategoryId,
sku,
} = this.props;
const labels = categoryLabels(draft.categoryIds, categories, rootCategoryId);
return (
<Box sx={columnSx}>
<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={sku}
editMode
onCategoryIdsChange={(categoryIds) => onChange({ ...draft, categoryIds })}
/>
</Box>
);
}
}
export default class ArticleViewName extends Component {
static contextType = StorageManagerContext;
handleSave = async (draft) => {
const { article: articleData, onArticleUpdated } = this.props;
const store = this.context;
const name = draft.name.trim();
const categoryIds = draft.categoryIds ?? [];
const [nameRes] = await Promise.all([
store.article.update(articleData.sku, { name }),
store.article.setCategories(articleData.sku, { categoryIds }),
]);
requireObject(nameRes, 'PATCH /api/article/:sku');
requireObject(nameRes.article, 'article');
onArticleUpdated?.(nameRes.article);
};
render() {
const { article: articleData, categories, rootCategoryId } = this.props;
const savedCategoryIds = articleData.categoryIds ?? [];
const value = { name: articleData.name ?? '', categoryIds: [...savedCategoryIds] };
return (
<EditablePanelTile
wide={3}
minHeight={0}
sx={headerTileSx}
value={value}
onSave={this.handleSave}
isDirty={draftDirty}
canSave={draftValid}
display={
<NameDisplay
name={value.name}
categoryIds={value.categoryIds}
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
edit={
<NameEdit
categories={categories}
rootCategoryId={rootCategoryId}
sku={articleData.sku}
/>
}
/>
);
}
}