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

@@ -14,6 +14,7 @@
| Gewicht setzen | `PUT /api/article/:sku/weight` | `{ weight: number }` | `{ article }` (Detail) |
| Kategorien setzen (replace-all) | `PUT /api/article/:sku/categories` | `{ categoryIds: number[] }` | `{ article }` (Detail) |
| Kategorie umbenennen | `PATCH /api/category/:id` | `{ name }` | `{ category: { id, name, slug } }` |
| Kategorie-Reihenfolge | `PUT /api/category/order` | `{ parentId, categoryIds: number[] }` | `{ categoryIds }` |
| Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` |
`Brand` = `{ id, name, gsprpinfo \| null }`.
@@ -39,6 +40,7 @@ Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/articl
- **Gewicht**: Zahlenfeld, `>= 0`. `400 Invalid weight` sonst.
- **Kategorien**: Mehrfachauswahl aus der Category-Tree; nur Kategorien derselben Company (sonst `400 Invalid categoryIds`). Leere Liste entfernt alle.
- **Kategorie umbenennen**: Inline/Formular im Category-Tree → `PATCH /api/category/:id` mit `{ name }`. Leeres `name` ⇒ `400 Name required`. Nach Save das zurückgegebene `{ id, name, slug }` im Baum aktualisieren.
- **Kategorie-Reihenfolge (Drag & Drop)**: nach Sortierung → `PUT /api/category/order` mit `{ parentId, categoryIds }` (die neuen Kind-IDs in gewünschter Reihenfolge). `parentId` ist der aktuelle Elternknoten. `400 Invalid category order` bei falscher Menge/Duplikaten; nur Children desselben Elternteils angeben.
- Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen.
### Fehlerbehandlung

7
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"react-dom": "^19.2.8",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
"devDependencies": {
@@ -6469,6 +6470,12 @@
"node": ">=20.0.0"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",

View File

@@ -26,6 +26,7 @@
"react-dom": "^19.2.8",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
"devDependencies": {

View File

@@ -345,6 +345,20 @@ export const category = {
body: { name },
});
},
/**
* PUT /api/category/order
* Body: { parentId, categoryIds: number[] } — sibling order of the children
* under `parentId` (top-down). categoryIds must be exactly the current child
* set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order.
* → 200 { categoryIds: number[] }
*/
reorder(parentId, categoryIds) {
return http('/api/category/order', {
method: 'PUT',
body: { parentId, categoryIds },
});
},
};
/**

View File

@@ -10,3 +10,15 @@ body {
overscroll-behavior-y: none;
touch-action: manipulation;
}
/* SortableJS: Kategorie-Reorder im Edit-Modus */
.sortable-ghost {
outline: 2px dashed #2e7d32;
outline-offset: 2px;
background-color: rgba(46, 125, 50, 0.12) !important;
}
.sortable-drag {
opacity: 1 !important;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
}

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));

View File

@@ -168,6 +168,27 @@ export const renameCategory = createAsyncThunk(
},
);
/**
* Reorder sibling categories (new order of children under the current parent).
* `PUT /api/category/order` → 200 { categoryIds: number[] }
*/
export const reorderCategories = createAsyncThunk(
'category/reorder',
async ({ categoryIds }, { rejectWithValue, getState }) => {
try {
const ids = requireArray(categoryIds, 'categoryIds');
const parentId = getState().category.currentCategoryId;
requireDefined(parentId, 'currentCategoryId');
const data = await category.reorder(parentId, ids);
requireObject(data, 'PUT /api/category/order');
requireArray(data.categoryIds, 'categoryIds');
return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk(
'category/delete',
async (categoryId, { rejectWithValue }) => {
@@ -309,6 +330,26 @@ const categorySlice = createSlice({
: row,
);
})
.addCase(reorderCategories.fulfilled, (state, action) => {
const { parentId, categoryIds } = action.payload;
if (!state.categories) return;
const parentKey = String(parentId);
const idToSort = new Map(
categoryIds.map((id, index) => [String(id), index]),
);
for (const node of Object.values(state.categories)) {
if (
node.parentId != null &&
String(node.parentId) === parentKey &&
idToSort.has(String(node.id))
) {
state.categories[String(node.id)] = {
...node,
sort: idToSort.get(String(node.id)),
};
}
}
})
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;