Files
dashfront/src/components/article/boxes/ArticleViewName.jsx
2026-10-08 20:12:37 +02:00

403 lines
12 KiB
JavaScript

import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { getCategoryState } from '../../../automats/categoryAutomat';
import artikelAutomat from '../../../automats/artikelAutomat';
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton, Typography } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog';
import { notifyError } from '../../../utils/notify';
import {
WHITE,
} from '../../../colors';
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 categoryIdsChanged(a, b) {
const aa = a ?? [];
const bb = b ?? [];
return (
aa.length !== bb.length ||
[...aa].map(String).sort().join(',') !== [...bb].map(String).sort().join(',')
);
}
function draftDirty(draft, saved) {
const nameDiff = draft.name.trim() !== String(saved.name ?? '').trim();
const seoDiff = String(draft.seo ?? '').trim() !== String(saved.seo ?? '').trim();
const catDiff = categoryIdsChanged(draft.categoryIds, saved.categoryIds);
return nameDiff || seoDiff || 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, targetPillId: null };
}
handleRemoveCategory = (removeId) => {
const { categoryIds, onCategoryIdsChange } = this.props;
const remaining = categoryIds.filter((id) => String(id) !== String(removeId));
onCategoryIdsChange?.(remaining);
};
handleOpenAddPicker = () => {
this.setState({ pickerOpen: true, targetPillId: null });
};
handleOpenMovePicker = (pillId) => {
this.setState({ pickerOpen: true, targetPillId: pillId });
};
handleClosePicker = () => {
this.setState({ pickerOpen: false, targetPillId: null });
};
handleCategorySelect = (selectedCategoryId) => {
const { categoryIds, onCategoryIdsChange } = this.props;
const { targetPillId } = this.state;
if (targetPillId != null) {
const updated = categoryIds.map((id) =>
String(id) === String(targetPillId) ? selectedCategoryId : id
);
const deduped = [...new Set(updated)];
onCategoryIdsChange?.(deduped);
} else {
if (!categoryIds.some((id) => String(id) === String(selectedCategoryId))) {
onCategoryIdsChange?.([...categoryIds, selectedCategoryId]);
}
}
};
render() {
const { categoryIds, editMode } = this.props;
const { pickerOpen, targetPillId } = this.state;
const { categories, rootCategoryId } = getCategoryState() ?? {};
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
const targetLabel = targetNode
? categoryDisplayName(targetNode.name, targetNode.id, rootCategoryId)
: '';
const dialogTitle =
targetPillId != null
? (targetLabel ? `Artikel aus Kategorie „${targetLabel}“ verschieben in…` : 'Artikel aus Kategorie verschieben in…')
: 'Kategorie hinzufügen';
const confirmLabel = targetPillId != null ? 'Hierher verschieben' : 'Hinzufügen';
return (
<>
<Box sx={pillsContainerSx}>
{labels.map(({ id, label }) => (
<Chip
key={id}
label={label}
size="small"
variant="outlined"
onClick={editMode ? () => this.handleOpenMovePicker(id) : undefined}
onDelete={
editMode && categoryIds.length > 1
? () => this.handleRemoveCategory(id)
: undefined
}
sx={{
cursor: editMode ? 'pointer' : 'default',
...(editMode
? {
'&:hover': {
bgcolor: 'action.hover',
},
}
: {}),
}}
/>
))}
{editMode ? (
<IconButton
size="small"
onClick={this.handleOpenAddPicker}
sx={{ ml: 0.5, width: 24, height: 24 }}
>
<AddIcon fontSize="small" />
</IconButton>
) : null}
</Box>
{editMode ? (
<CategoryPickerDialog
open={pickerOpen}
onClose={this.handleClosePicker}
categories={categories}
rootCategoryId={rootCategoryId}
currentCategoryIds={categoryIds}
selectedId={targetPillId ?? rootCategoryId}
title={dialogTitle}
confirmLabel={confirmLabel}
mode="single"
onSelectCategory={this.handleCategorySelect}
/>
) : null}
</>
);
}
}
class NameDisplay extends Component {
render() {
const { name, seo, categoryIds } = this.props;
return (
<Box sx={columnSx}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<SingleLineTextEdit
label="Name"
value={name}
readOnly
onChange={() => {}}
sx={articleNameFieldHiddenSx}
/>
{seo ? (
<Box sx={{ px: 1.75, mt: -0.5, userSelect: 'text' }}>
<Typography variant="body2" color="text.secondary">
{seo}
</Typography>
</Box>
) : null}
</Box>
{(categoryIds?.length ?? 0) > 0 ? (
<CategoryPills categoryIds={categoryIds} editMode={false} />
) : null}
</Box>
);
}
}
class NameEdit extends Component {
constructor(props) {
super(props);
this.state = {
name: props.name,
seo: props.seo ?? '',
categoryIds: [...(props.categoryIds ?? [])],
};
}
componentDidMount() {
const { onNameDraftChange, onSeoDraftChange, onCategoryIdsDraftChange } = this.props;
onNameDraftChange?.(this.state.name);
onSeoDraftChange?.(this.state.seo);
onCategoryIdsDraftChange?.(this.state.categoryIds);
}
handleNameChange = (name) => {
this.setState({ name });
this.props.onNameDraftChange?.(name);
};
handleSeoChange = (seo) => {
this.setState({ seo });
this.props.onSeoDraftChange?.(seo);
};
handleCategoryIdsChange = (categoryIds) => {
this.setState({ categoryIds });
this.props.onCategoryIdsDraftChange?.(categoryIds);
};
render() {
const { name, seo, categoryIds } = this.state;
return (
<Box sx={columnSx}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<SingleLineTextEdit
label="Name"
value={name}
onChange={this.handleNameChange}
sx={articleNameFieldSx}
/>
<Box sx={{ mt: 1 }}>
<SingleLineTextEdit
label="SEO"
value={seo}
size="small"
onChange={this.handleSeoChange}
/>
</Box>
</Box>
<CategoryPills
categoryIds={categoryIds}
editMode
onCategoryIdsChange={this.handleCategoryIdsChange}
/>
</Box>
);
}
}
export default class ArticleViewName extends PureComponent {
constructor(props) {
super(props);
this.state = {
nameDraft: null,
seoDraft: null,
categoryIdsDraft: null,
};
}
handleNameDraftChange = (name) => {
this.setState({ nameDraft: name });
};
handleSeoDraftChange = (seo) => {
this.setState({ seoDraft: seo });
};
handleCategoryIdsDraftChange = (categoryIds) => {
this.setState({ categoryIdsDraft: categoryIds });
};
handleSave = async () => {
const { article: articleData, onArticleUpdated } = this.props;
const { nameDraft, seoDraft, categoryIdsDraft } = this.state;
const name = nameDraft != null ? nameDraft.trim() : String(articleData.name ?? '').trim();
const seo = seoDraft != null ? seoDraft.trim() : String(articleData.seo ?? articleData.seoDescription ?? '').trim();
const categoryIds = categoryIdsDraft ?? (articleData.categoryIds ?? []);
const nameChanged = name !== String(articleData.name ?? '').trim();
const seoChanged = seo !== String(articleData.seo ?? articleData.seoDescription ?? '').trim();
const categoriesChanged = categoryIdsChanged(categoryIds, articleData.categoryIds);
let article = null;
if (nameChanged || seoChanged) {
const updateBody = {};
if (nameChanged) updateBody.name = name;
if (seoChanged) updateBody.seo = seo;
const res = await artikelAutomat.actions.update(articleData.sku, updateBody);
article = res.article;
}
if (categoriesChanged) {
const catRes = await artikelAutomat.actions.setCategories(articleData.sku, categoryIds);
article = catRes.article;
}
if (article) onArticleUpdated?.(article);
this.setState({ nameDraft: null, seoDraft: null, categoryIdsDraft: null });
};
render() {
const { article: articleData } = this.props;
const { nameDraft, seoDraft, categoryIdsDraft } = this.state;
const savedCategoryIds = articleData.categoryIds ?? [];
const savedSeo = articleData.seo ?? articleData.seoDescription ?? '';
const value = {
name: articleData.name ?? '',
seo: savedSeo,
categoryIds: [...savedCategoryIds],
};
const draft =
nameDraft != null && categoryIdsDraft != null
? {
name: nameDraft,
seo: seoDraft ?? savedSeo,
categoryIds: categoryIdsDraft,
}
: null;
return (
<EditablePanelTile
wide={3}
minHeight={0}
sx={headerTileSx}
color={WHITE}
value={value}
onSave={this.handleSave}
onError={notifyError}
isDirty={() => draft != null && draftDirty(draft, value)}
canSave={() => draft != null && draftValid(draft)}
display={
<NameDisplay
name={value.name}
seo={value.seo}
categoryIds={value.categoryIds}
/>
}
edit={
<NameEdit
name={value.name}
seo={value.seo}
categoryIds={value.categoryIds}
onNameDraftChange={this.handleNameDraftChange}
onSeoDraftChange={this.handleSeoDraftChange}
onCategoryIdsDraftChange={this.handleCategoryIdsDraftChange}
/>
}
/>
);
}
}