From 8577a63b06d4b3795348cbf83d57a673ce7b4add Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 00:22:46 +0200 Subject: [PATCH] u --- TODO.md | 2 +- package-lock.json | 7 + package.json | 1 + src/api/index.js | 12 ++ src/components/tiles/CategoryTile.jsx | 6 +- src/index.css | 25 +++- src/pages/artikel/kategorien.jsx | 178 +++++++++++++++++--------- src/store/categorySlice.js | 26 ++++ 8 files changed, 189 insertions(+), 68 deletions(-) diff --git a/TODO.md b/TODO.md index f16e73d..b54ffcc 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,7 @@ | 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 }` | -| **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` | `Brand` = `{ id, name, gsprpinfo \| null }`. diff --git a/package-lock.json b/package-lock.json index a02c1ba..4335f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", "@reduxjs/toolkit": "^2.12.0", + "@shopify/draggable": "^1.2.1", "notistack": "^3.0.2", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -2936,6 +2937,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/package.json b/package.json index 7e663c7..de50bae 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", "@reduxjs/toolkit": "^2.12.0", + "@shopify/draggable": "^1.2.1", "notistack": "^3.0.2", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/api/index.js b/src/api/index.js index c9ab62e..985c254 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -359,6 +359,18 @@ export const category = { 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 }, + }); + }, }; /** diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx index 0dc6106..af2541b 100644 --- a/src/components/tiles/CategoryTile.jsx +++ b/src/components/tiles/CategoryTile.jsx @@ -10,8 +10,8 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import { Tile, tileBaseSx } from './Tile'; /** - * Top edit band (upper third) with a move handle on the left and a tree - * (drop-into-subcategory) handle on the right. + * Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory + * handle (Shopify Draggable Droppable). */ function TopBand() { return ( @@ -37,7 +37,7 @@ function TopBand() { { @@ -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 = () => { const el = this.gridRef.current; 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, { draggable: '.sortable-item', - handle: '.sortable-handle, .sortable-tree', + handle: '.sortable-handle', animation: 150, ghostClass: 'sortable-ghost', dragClass: 'sortable-drag', @@ -133,10 +242,6 @@ class ArtikelKategorienPage extends Component { }; destroySortable = () => { - const el = this.gridRef.current; - if (el) { - el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true); - } if (this.sortable) { this.sortable.destroy(); this.sortable = null; @@ -153,50 +258,15 @@ class ArtikelKategorienPage extends Component { .map(Number); }; - handleGridMouseDownCapture = (event) => { - // 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'); - } + handleSortStart = () => { // Snapshot der Reihenfolge vor dem ersten Drag. this.checkpointOrder = this.sortableIds(); }; handleSort = () => { - // Sortable-Klassen für den nächsten Drag zurücksetzen. - 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 ids = this.sortableIds(); const before = this.checkpointOrder ?? []; 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 ( before.length === 0 || before.length !== ids.length || @@ -212,24 +282,9 @@ class ArtikelKategorienPage extends Component { // Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern — // die Sortable-Instanz bleibt dadurch funktionsfähig. 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) => { const ids = this.sortableIds(); const index = ids.indexOf(category.id); @@ -545,6 +600,7 @@ const mapDispatchToProps = { navigateToCategory, renameCategory, deleteCategory, + moveCategory, reorderCategories, }; diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index 7898bcc..10f0e69 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -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( 'category/delete', 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) => { const created = action.payload; if (created == null) return;