u
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -15,7 +15,7 @@
|
|||||||
| 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 }` |
|
| Kategorie-Reihenfolge | `PUT /api/category/order` | `{ parentId, categoryIds: number[] }` | `{ categoryIds }` |
|
||||||
| **Kategorie verschieben** (VORSCHLAG, offen) | `PATCH /api/category/:id` | `{ parentId: number }` | `{ category: { id, parentId, name, slug } }` |
|
| **Kategorie verschieben** (Frontend fertig, Backend-Vorschlag offen) | `PATCH /api/category/:id` | `{ parentId: number }` | `{ category: { id, parentId, name, slug } }` |
|
||||||
| 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 }`.
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
|
"@shopify/draggable": "^1.2.1",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
@@ -2936,6 +2937,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@shopify/draggable": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shopify/draggable/-/draggable-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-hYKhd99nl1mkO4ybA8i3rxvZgYS2A4Imcxpq7BoUu7h4LmFt997YxHLy8Mm5J7ST+G4DTQXqBsTx9mDid6piZg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
|
"@shopify/draggable": "^1.2.1",
|
||||||
"notistack": "^3.0.2",
|
"notistack": "^3.0.2",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -359,6 +359,18 @@ export const category = {
|
|||||||
body: { parentId, categoryIds },
|
body: { parentId, categoryIds },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/category/:id [VORSCHLAG — Route/response fehlt noch in dashback]
|
||||||
|
* Body: { parentId } — moves the category under a new parent.
|
||||||
|
* → 200 { category: { id, parentId, name, slug } }
|
||||||
|
*/
|
||||||
|
move(categoryId, parentId) {
|
||||||
|
return http(`/api/category/${categoryId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { parentId },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
|||||||
import { Tile, tileBaseSx } from './Tile';
|
import { Tile, tileBaseSx } from './Tile';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top edit band (upper third) with a move handle on the left and a tree
|
* Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory
|
||||||
* (drop-into-subcategory) handle on the right.
|
* handle (Shopify Draggable Droppable).
|
||||||
*/
|
*/
|
||||||
function TopBand() {
|
function TopBand() {
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +37,7 @@ function TopBand() {
|
|||||||
<DragHandleIcon sx={{ fontSize: 18 }} />
|
<DragHandleIcon sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="sortable-tree"
|
className="droppable-handle"
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="In Unterkategorie ablegen"
|
aria-label="In Unterkategorie ablegen"
|
||||||
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' } }}
|
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' } }}
|
||||||
|
|||||||
@@ -23,7 +23,26 @@ body {
|
|||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tree-Drag (In-Unterkategorie-ablegen): kein Ghost/Shadow, Kacheln bleiben fest */
|
/* Shopify Droppable: aktuell anvisierte Ziel-Kachel hervorheben */
|
||||||
.sortable-tree-ghost {
|
.tree-drop-target {
|
||||||
display: none !important;
|
outline: 3px solid #1976d2;
|
||||||
|
outline-offset: -3px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Während des Tree-Draggings keine Hover-/Textselektions-Effekte auf den
|
||||||
|
Ziel-Kacheln: interaktive Kinder (Buttons, Inputs) vom Maus-Event abkoppeln,
|
||||||
|
damit keine :hover ausgelöst wird und der Cursor durchgängig 'grabbing' bleibt. */
|
||||||
|
body.tree-dragging {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.tree-dragging button,
|
||||||
|
body.tree-dragging input,
|
||||||
|
body.tree-dragging textarea,
|
||||||
|
body.tree-dragging .MuiButtonBase-root,
|
||||||
|
body.tree-dragging .MuiInputBase-root {
|
||||||
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 Sortable from 'sortablejs';
|
||||||
|
import { Droppable } from '@shopify/draggable';
|
||||||
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';
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
categoryDisplayName,
|
categoryDisplayName,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
|
moveCategory,
|
||||||
loadCategoryArticles,
|
loadCategoryArticles,
|
||||||
loadCategoryTree,
|
loadCategoryTree,
|
||||||
navigateToCategory,
|
navigateToCategory,
|
||||||
@@ -57,8 +59,10 @@ class ArtikelKategorienPage extends Component {
|
|||||||
this.state = { drafts: {} };
|
this.state = { drafts: {} };
|
||||||
this.gridRef = createRef();
|
this.gridRef = createRef();
|
||||||
this.sortable = null;
|
this.sortable = null;
|
||||||
|
this.droppable = null;
|
||||||
this.checkpointOrder = null;
|
this.checkpointOrder = null;
|
||||||
this.treeDragId = null;
|
this.pendingDropId = null;
|
||||||
|
this.dropTargetEl = 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);
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
this.syncCategoryFromUrl();
|
this.syncCategoryFromUrl();
|
||||||
this.ensureArticlesLoaded();
|
this.ensureArticlesLoaded();
|
||||||
this.syncSortable();
|
this.syncSortable();
|
||||||
|
this.syncDroppable();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -96,11 +101,13 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
if (this.props.renameMode !== prevProps.renameMode) {
|
if (this.props.renameMode !== prevProps.renameMode) {
|
||||||
this.syncSortable();
|
this.syncSortable();
|
||||||
|
this.syncDroppable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.destroySortable();
|
this.destroySortable();
|
||||||
|
this.destroyDroppable();
|
||||||
}
|
}
|
||||||
|
|
||||||
syncSortable = () => {
|
syncSortable = () => {
|
||||||
@@ -114,15 +121,117 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
syncDroppable = () => {
|
||||||
|
if (this.props.renameMode) {
|
||||||
|
this.destroyDroppable();
|
||||||
|
this.activateDroppable();
|
||||||
|
} else {
|
||||||
|
this.destroyDroppable();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activateDroppable = () => {
|
||||||
|
const el = this.gridRef.current;
|
||||||
|
if (!el || this.droppable) return;
|
||||||
|
this.pendingDropId = null;
|
||||||
|
this.droppable = new Droppable(el, {
|
||||||
|
draggable: '.sortable-item',
|
||||||
|
dropzone: '.sortable-item',
|
||||||
|
handle: '.droppable-handle',
|
||||||
|
delay: 0,
|
||||||
|
mirror: { constrainDimensions: true },
|
||||||
|
// Droppable markiert sonst alle Dropzones gleichzeitig mit der
|
||||||
|
// --active-Klasse. Das deaktivieren wir und heben stattdessen nur das
|
||||||
|
// aktuell anvisierte Ziel über tree-drop-target hervor.
|
||||||
|
classes: { 'droppable:active': '', 'droppable:occupied': '' },
|
||||||
|
});
|
||||||
|
this.droppable.on('drag:start', this.handleTreeDragStart);
|
||||||
|
this.droppable.on('drag:over', this.handleDropOver);
|
||||||
|
this.droppable.on('drag:out', this.handleDropOut);
|
||||||
|
this.droppable.on('droppable:dropped', this.handleDropped);
|
||||||
|
this.droppable.on('droppable:returned', this.handleDroppedReturned);
|
||||||
|
this.droppable.on('drag:stop', this.handleDragStop);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTreeDragStart = () => {
|
||||||
|
// Während des Tree-Draggings native Textselektion unterbinden, damit der
|
||||||
|
// Cursor kein Text-I-Cursor wird und Textareas keine Hover-Selektion zeigen.
|
||||||
|
document.body.classList.add('tree-dragging');
|
||||||
|
};
|
||||||
|
|
||||||
|
destroyDroppable = () => {
|
||||||
|
if (this.droppable) {
|
||||||
|
this.droppable.destroy();
|
||||||
|
this.droppable = null;
|
||||||
|
}
|
||||||
|
this.pendingDropId = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDropped = (event) => {
|
||||||
|
// Reiner DOM-Schutz: verhindert, dass Droppable das Quell-Element per
|
||||||
|
// appendChild in die Dropzone verschiebt. Das Ziel-Tracking läuft über
|
||||||
|
// drag:over/drag:out (pendingDropId), nicht über dieses Event.
|
||||||
|
event.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDropOver = (event) => {
|
||||||
|
const tile =
|
||||||
|
event?.over && typeof event.over.closest === 'function'
|
||||||
|
? event.over.closest('.sortable-item')
|
||||||
|
: null;
|
||||||
|
if (!tile) return;
|
||||||
|
// Die gezogene Kachel selbst ist kein gültiges Drop-Ziel.
|
||||||
|
const sourceId = Number(event?.source?.dataset?.id);
|
||||||
|
const targetId = Number(tile.dataset.id);
|
||||||
|
if (sourceId && targetId === sourceId) {
|
||||||
|
this.pendingDropId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clearDropTarget();
|
||||||
|
this.pendingDropId = targetId;
|
||||||
|
this.dropTargetEl = tile;
|
||||||
|
tile.classList.add('tree-drop-target');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDropOut = () => {
|
||||||
|
this.pendingDropId = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
clearDropTarget = () => {
|
||||||
|
if (this.dropTargetEl) {
|
||||||
|
this.dropTargetEl.classList.remove('tree-drop-target');
|
||||||
|
this.dropTargetEl = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDroppedReturned = (event) => {
|
||||||
|
this.pendingDropId = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
event.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragStop = (event) => {
|
||||||
|
document.body.classList.remove('tree-dragging');
|
||||||
|
const categoryId = Number(event?.source?.dataset?.id);
|
||||||
|
const targetId = this.pendingDropId;
|
||||||
|
this.pendingDropId = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
if (!categoryId || !targetId || categoryId === targetId) return;
|
||||||
|
void this.props
|
||||||
|
.moveCategory({ categoryId, parentId: targetId })
|
||||||
|
.unwrap()
|
||||||
|
.catch(notifyError);
|
||||||
|
};
|
||||||
|
|
||||||
activateSortable = () => {
|
activateSortable = () => {
|
||||||
const el = this.gridRef.current;
|
const el = this.gridRef.current;
|
||||||
if (!el || this.sortable) return;
|
if (!el || this.sortable) return;
|
||||||
// Capture-Listener im Grid: läuft vor SortableJS' Listener und vor Reacts
|
|
||||||
// synthetischem mousedown — erkennt die Drag-Quelle (Tree vs. Move) zuverlässig.
|
|
||||||
el.addEventListener('mousedown', this.handleGridMouseDownCapture, true);
|
|
||||||
this.sortable = Sortable.create(el, {
|
this.sortable = Sortable.create(el, {
|
||||||
draggable: '.sortable-item',
|
draggable: '.sortable-item',
|
||||||
handle: '.sortable-handle, .sortable-tree',
|
handle: '.sortable-handle',
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: 'sortable-ghost',
|
ghostClass: 'sortable-ghost',
|
||||||
dragClass: 'sortable-drag',
|
dragClass: 'sortable-drag',
|
||||||
@@ -133,10 +242,6 @@ class ArtikelKategorienPage extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
destroySortable = () => {
|
destroySortable = () => {
|
||||||
const el = this.gridRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true);
|
|
||||||
}
|
|
||||||
if (this.sortable) {
|
if (this.sortable) {
|
||||||
this.sortable.destroy();
|
this.sortable.destroy();
|
||||||
this.sortable = null;
|
this.sortable = null;
|
||||||
@@ -153,50 +258,15 @@ class ArtikelKategorienPage extends Component {
|
|||||||
.map(Number);
|
.map(Number);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleGridMouseDownCapture = (event) => {
|
handleSortStart = () => {
|
||||||
// Native Capture-Phase: läuft VOR SortableJS' Bubbling-Listener und vor
|
|
||||||
// Reacts synthetischem Event. Das echte DOM-Ziel wird so zuverlässig erkannt.
|
|
||||||
const target = event.target;
|
|
||||||
if (!target || typeof target.closest !== 'function') return;
|
|
||||||
const treeEl = target.closest('.sortable-tree');
|
|
||||||
const isTree = treeEl != null;
|
|
||||||
this.treeDragId = isTree
|
|
||||||
? Number(treeEl?.closest('.sortable-item')?.dataset?.id)
|
|
||||||
: null;
|
|
||||||
// Beim Tree-Drag Sortable-Deaktivierung (sort=false) setzen, damit nichts umsortiert.
|
|
||||||
this.sortable?.option('sort', !isTree);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleSortStart = (evt) => {
|
|
||||||
const isTree = this.treeDragId != null;
|
|
||||||
if (isTree) {
|
|
||||||
// Kein Ghost/Shadow — Kacheln bleiben fest, nur das Ziel wird sichtbar.
|
|
||||||
this.sortable?.option('ghostClass', 'sortable-tree-ghost');
|
|
||||||
this.sortable?.option('dragClass', '');
|
|
||||||
} else {
|
|
||||||
this.sortable?.option('ghostClass', 'sortable-ghost');
|
|
||||||
this.sortable?.option('dragClass', 'sortable-drag');
|
|
||||||
}
|
|
||||||
// Snapshot der Reihenfolge vor dem ersten Drag.
|
// Snapshot der Reihenfolge vor dem ersten Drag.
|
||||||
this.checkpointOrder = this.sortableIds();
|
this.checkpointOrder = this.sortableIds();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSort = () => {
|
handleSort = () => {
|
||||||
// Sortable-Klassen für den nächsten Drag zurücksetzen.
|
const ids = this.sortableIds();
|
||||||
this.sortable?.option('ghostClass', 'sortable-ghost');
|
|
||||||
this.sortable?.option('dragClass', 'sortable-drag');
|
|
||||||
this.sortable?.option('sort', true);
|
|
||||||
// Tree-Drag (in Unterkategorie ablegen): Geschwister-Reihenfolge nicht ändern.
|
|
||||||
const before = this.checkpointOrder ?? [];
|
const before = this.checkpointOrder ?? [];
|
||||||
this.checkpointOrder = null;
|
this.checkpointOrder = null;
|
||||||
if (this.treeDragId != null) {
|
|
||||||
// Kacheln visuell an Original-Position zurückdrehen — kein Reorder-Call.
|
|
||||||
this.treeDragId = null;
|
|
||||||
if (before.length) this.revertDomOrder(before);
|
|
||||||
this.clearSortableState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ids = this.sortableIds();
|
|
||||||
if (
|
if (
|
||||||
before.length === 0 ||
|
before.length === 0 ||
|
||||||
before.length !== ids.length ||
|
before.length !== ids.length ||
|
||||||
@@ -212,24 +282,9 @@ class ArtikelKategorienPage extends Component {
|
|||||||
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
|
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
|
||||||
// die Sortable-Instanz bleibt dadurch funktionsfähig.
|
// die Sortable-Instanz bleibt dadurch funktionsfähig.
|
||||||
this.revertDomOrder(before);
|
this.revertDomOrder(before);
|
||||||
this.clearSortableState();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Entfernt verbliebene SortableJS-Drag/Chosen/Ghost-Klassen von allen
|
|
||||||
* Kacheln. Nötig, weil revertDomOrder die DOM-Knoten umsortiert und
|
|
||||||
* SortableJS sein internes Cleanup dann ggf. nicht mehr korrekt anwendet.
|
|
||||||
*/
|
|
||||||
clearSortableState = () => {
|
|
||||||
const container = this.gridRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const classes = ['sortable-chosen', 'sortable-drag', 'sortable-ghost', 'sortable-tree-ghost'];
|
|
||||||
for (const el of container.querySelectorAll('.sortable-item')) {
|
|
||||||
el.classList.remove(...classes);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveCategory = (category, direction) => {
|
handleMoveCategory = (category, direction) => {
|
||||||
const ids = this.sortableIds();
|
const ids = this.sortableIds();
|
||||||
const index = ids.indexOf(category.id);
|
const index = ids.indexOf(category.id);
|
||||||
@@ -545,6 +600,7 @@ const mapDispatchToProps = {
|
|||||||
navigateToCategory,
|
navigateToCategory,
|
||||||
renameCategory,
|
renameCategory,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
|
moveCategory,
|
||||||
reorderCategories,
|
reorderCategories,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,24 @@ export const reorderCategories = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a category under a new parent (drop into subcategory).
|
||||||
|
* Backend: PATCH /api/category/:id Body: { parentId } → { category }.
|
||||||
|
* (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.)
|
||||||
|
*/
|
||||||
|
export const moveCategory = createAsyncThunk(
|
||||||
|
'category/move',
|
||||||
|
async ({ categoryId, parentId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await category.move(categoryId, parentId);
|
||||||
|
requireObject(data.category, 'PATCH /api/category/:id.category');
|
||||||
|
return normalizeCategoryNode(data.category);
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const deleteCategory = createAsyncThunk(
|
export const deleteCategory = createAsyncThunk(
|
||||||
'category/delete',
|
'category/delete',
|
||||||
async (categoryId, { rejectWithValue }) => {
|
async (categoryId, { rejectWithValue }) => {
|
||||||
@@ -350,6 +368,14 @@ const categorySlice = createSlice({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.addCase(moveCategory.fulfilled, (state, action) => {
|
||||||
|
const moved = action.payload;
|
||||||
|
if (moved == null || state.categories == null) return;
|
||||||
|
state.categories[String(moved.id)] = {
|
||||||
|
...(state.categories[String(moved.id)] ?? {}),
|
||||||
|
...moved,
|
||||||
|
};
|
||||||
|
})
|
||||||
.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