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:
2
TODO.md
2
TODO.md
@@ -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
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user