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) |
|
| Gewicht setzen | `PUT /api/article/:sku/weight` | `{ weight: number }` | `{ article }` (Detail) |
|
||||||
| Kategorien setzen (replace-all) | `PUT /api/article/:sku/categories` | `{ categoryIds: 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 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` |
|
| Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` |
|
||||||
|
|
||||||
`Brand` = `{ id, name, gsprpinfo \| null }`.
|
`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.
|
- **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.
|
- **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 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.
|
- Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen.
|
||||||
|
|
||||||
### Fehlerbehandlung
|
### Fehlerbehandlung
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -6469,6 +6470,12 @@
|
|||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"sortablejs": "^1.15.7",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -345,6 +345,20 @@ export const category = {
|
|||||||
body: { name },
|
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;
|
overscroll-behavior-y: none;
|
||||||
touch-action: manipulation;
|
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 { connect } from 'react-redux';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import Sortable from 'sortablejs';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
|
||||||
import { NewArticleTile } from '../../components/article';
|
import { NewArticleTile } from '../../components/article';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
loadCategoryTree,
|
loadCategoryTree,
|
||||||
navigateToCategory,
|
navigateToCategory,
|
||||||
renameCategory,
|
renameCategory,
|
||||||
|
reorderCategories,
|
||||||
selectCanNavigateBack,
|
selectCanNavigateBack,
|
||||||
selectCategoryArticles,
|
selectCategoryArticles,
|
||||||
selectCategoryArticlesStatus,
|
selectCategoryArticlesStatus,
|
||||||
@@ -52,6 +54,9 @@ class ArtikelKategorienPage extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { drafts: {} };
|
this.state = { drafts: {} };
|
||||||
|
this.gridRef = createRef();
|
||||||
|
this.sortable = null;
|
||||||
|
this.checkpointOrder = null;
|
||||||
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
|
||||||
this.handleDraftChange = this.handleDraftChange.bind(this);
|
this.handleDraftChange = this.handleDraftChange.bind(this);
|
||||||
}
|
}
|
||||||
@@ -60,6 +65,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
this.ensureTreeLoaded();
|
this.ensureTreeLoaded();
|
||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
this.ensureArticlesLoaded();
|
this.ensureArticlesLoaded();
|
||||||
|
this.syncSortable();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -85,8 +91,99 @@ class ArtikelKategorienPage extends Component {
|
|||||||
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
|
if (this.props.currentCategoryId !== prevProps.currentCategoryId) {
|
||||||
this.ensureArticlesLoaded();
|
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 = () => {
|
ensureTreeLoaded = () => {
|
||||||
const { companyId, treeStatus } = this.props;
|
const { companyId, treeStatus } = this.props;
|
||||||
if (!companyId || treeStatus !== 'idle') return;
|
if (!companyId || treeStatus !== 'idle') return;
|
||||||
@@ -250,7 +347,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={gridSx}>
|
<Box ref={this.gridRef} sx={gridSx}>
|
||||||
{canGoBack && (
|
{canGoBack && (
|
||||||
<BackTile onClick={this.handleNavigateBack} />
|
<BackTile onClick={this.handleNavigateBack} />
|
||||||
)}
|
)}
|
||||||
@@ -265,6 +362,8 @@ class ArtikelKategorienPage extends Component {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-id={String(item.id)}
|
||||||
|
className="sortable-item"
|
||||||
component="form"
|
component="form"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -272,7 +371,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
...tileBaseSx,
|
...tileBaseSx,
|
||||||
cursor: 'default',
|
cursor: 'grab',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
my: 0.5,
|
my: 0.5,
|
||||||
@@ -359,6 +458,7 @@ const mapDispatchToProps = {
|
|||||||
loadCategoryArticles,
|
loadCategoryArticles,
|
||||||
navigateToCategory,
|
navigateToCategory,
|
||||||
renameCategory,
|
renameCategory,
|
||||||
|
reorderCategories,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
|
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(
|
export const deleteCategory = createAsyncThunk(
|
||||||
'category/delete',
|
'category/delete',
|
||||||
async (categoryId, { rejectWithValue }) => {
|
async (categoryId, { rejectWithValue }) => {
|
||||||
@@ -309,6 +330,26 @@ const categorySlice = createSlice({
|
|||||||
: row,
|
: 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) => {
|
.addCase(createArticle.fulfilled, (state, action) => {
|
||||||
const created = action.payload;
|
const created = action.payload;
|
||||||
if (created == null) return;
|
if (created == null) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user