Add category rename and drag-and-drop reordering in edit mode

- Edit toggle behind the "Kategorien" title enables an edit mode.
- In edit mode all category tiles become inline rename fields (no outline,
  distinct background, small margins); articles and "new article" are hidden.
- Rename via PATCH /api/category/:id; reorder via PUT /api/category/order
  (parentId + categoryIds) with SortableJS.
- Only call backend when order actually changed; revert DOM order on error
  so further dragging keeps working.
- Redux: renameCategory + reorderCategories thunks, renameMode state.
This commit is contained in:
seb
2026-08-02 19:43:38 +02:00
parent 5309b1174b
commit 2dd14bac91
7 changed files with 180 additions and 3 deletions

View File

@@ -1,10 +1,11 @@
import { Component } from 'react';
import { Component, createRef } from 'react';
import { connect } from 'react-redux';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import Sortable from 'sortablejs';
import BackendError from '../../components/BackendError';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
@@ -31,6 +32,7 @@ import {
loadCategoryTree,
navigateToCategory,
renameCategory,
reorderCategories,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
@@ -52,6 +54,9 @@ class ArtikelKategorienPage extends Component {
constructor(props) {
super(props);
this.state = { drafts: {} };
this.gridRef = createRef();
this.sortable = null;
this.checkpointOrder = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
@@ -60,6 +65,7 @@ class ArtikelKategorienPage extends Component {
this.ensureTreeLoaded();
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
this.syncSortable();
}
componentDidUpdate(prevProps) {
@@ -85,8 +91,99 @@ class ArtikelKategorienPage extends Component {
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
this.ensureArticlesLoaded();
}
if (this.props.renameMode !== prevProps.renameMode) {
this.syncSortable();
}
}
componentWillUnmount() {
this.destroySortable();
}
syncSortable = () => {
if (this.props.renameMode) {
this.activateSortable();
} else {
this.destroySortable();
}
};
activateSortable = () => {
const el = this.gridRef.current;
if (!el || this.sortable) return;
this.sortable = Sortable.create(el, {
draggable: '.sortable-item',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
filter: '.not-sortable',
onStart: this.handleSortStart,
onEnd: this.handleSort,
});
};
destroySortable = () => {
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
this.checkpointOrder = null;
};
sortableIds = () => {
const container = this.gridRef.current;
if (!container) return [];
return [...container.querySelectorAll('.sortable-item')]
.map((el) => el.dataset.id)
.filter(Boolean)
.map(Number);
};
handleSortStart = () => {
// Snapshot der Reihenfolge vor dem ersten Drag.
this.checkpointOrder = this.sortableIds();
};
handleSort = () => {
const ids = this.sortableIds();
const before = this.checkpointOrder ?? [];
this.checkpointOrder = null;
if (
before.length === 0 ||
before.length !== ids.length ||
before.every((id, index) => id === ids[index])
) {
return; // nichts geändert
}
void this.props
.reorderCategories({ categoryIds: ids })
.unwrap()
.catch((err) => {
notifyError(err);
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
// die Sortable-Instanz bleibt dadurch funktionsfähig.
this.revertDomOrder(before);
});
};
revertDomOrder = (order) => {
const container = this.gridRef.current;
if (!container) return;
const items = [...container.querySelectorAll('.sortable-item')];
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
// Erste nicht-sortierbare Kachel (= BackTile o. Ä.), davor werden die Items eingefügt.
const anchor =
[...container.children].find((el) => !el.classList.contains('sortable-item')) ??
null;
for (const id of order) {
const el = byId.get(id);
if (!el) continue;
if (anchor) container.insertBefore(el, anchor);
else container.appendChild(el);
}
};
ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props;
if (!companyId || treeStatus !== 'idle') return;
@@ -250,7 +347,7 @@ class ArtikelKategorienPage extends Component {
/>
)}
<Box sx={gridSx}>
<Box ref={this.gridRef} sx={gridSx}>
{canGoBack && (
<BackTile onClick={this.handleNavigateBack} />
)}
@@ -265,6 +362,8 @@ class ArtikelKategorienPage extends Component {
return (
<Box
key={item.id}
data-id={String(item.id)}
className="sortable-item"
component="form"
onSubmit={(event) => {
event.preventDefault();
@@ -272,7 +371,7 @@ class ArtikelKategorienPage extends Component {
}}
sx={{
...tileBaseSx,
cursor: 'default',
cursor: 'grab',
alignItems: 'center',
justifyContent: 'center',
my: 0.5,
@@ -359,6 +458,7 @@ const mapDispatchToProps = {
loadCategoryArticles,
navigateToCategory,
renameCategory,
reorderCategories,
};
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));