From 1d5141d0738d2006414464ad70831779bed8b512 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 15:20:25 +0200 Subject: [PATCH] Temporary broken drag and drop --- .../categories/product-categories-item.tsx | 25 ++- .../categories/product-categories-tree.tsx | 205 +++++++++++++++--- .../store/categories/product-categories.tsx | 39 +++- .../modules/store/product-categories.scss | 16 +- package.json | 2 + yarn.lock | 36 +++ 6 files changed, 286 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 1615df658..9799eed14 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { FabButton } from '../../base/fab-button'; import { DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, + isChild?: boolean, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -14,9 +16,22 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition + } = useSortable({ id: category.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + return ( -
+

{category.name}

[count] @@ -33,7 +48,9 @@ export const ProductCategoriesItem: React.FC = ({ pr onSuccess={onSuccess} onError={onError} />
- } className='draghandle' /> +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 132a2328c..01b8797af 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useEffect, useState } from 'react'; +import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; -import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../../base/fab-button'; -import { ManageProductCategory } from './manage-product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { productCategories: Array, + onDnd: (list: Array) => void, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -13,30 +16,180 @@ interface ProductCategoriesTreeProps { /** * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [hiddenChildren, setHiddenChildren] = useState({}); + + // Initialize state from props, sorting list as a tree + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); + const activeChildren = categoriesList.filter(c => c.parent_id === active.id); + if (activeChildren.length) { + setHiddenChildren({ [active.id]: activeChildren }); + const activeIndex = categoriesList.findIndex(el => el.id === active.id); + const tmpList = [...categoriesList]; + tmpList.splice(activeIndex + 1, activeChildren.length); + setCategoriesList(tmpList); + } + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, over }: DragMoveEvent) => { + console.log(findCategory(over.id).name); + if (delta.x > 48) { + console.log('Child'); + } else { + console.log('Parent'); + } + }; + + /** + * Update categories list after an item was dropped + */ + + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + + // si déplacé sur une autre catégorie… + if (active.id !== over.id) { + // liste d'ids des catégories visibles + const previousIdsOrder = over?.data.current.sortable.items; + // index dans previousIdsOrder de la catégorie déplacée + const oldIndex = active.data.current.sortable.index; + // index dans previousIdsOrder de la catégorie de réception + const newIndex = over.data.current.sortable.index; + // liste de catégories mise à jour après le drop + const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); + // id du parent de la catégorie de réception + const newParentId = categoriesList[newIndex].parent_id; + + // nouvelle liste de catégories classées par newIdsOrder + newOrder = newIdsOrder.map(sortedId => { + // catégorie courante du map retrouvée grâce à l'id + const categoryFromId = findCategory(sortedId); + // si catégorie courante = catégorie déplacée… + if (categoryFromId.id === active.id) { + // maj du parent + categoryFromId.parent_id = newParentId; + } + // retour de la catégorie courante + return categoryFromId; + }); + } + // insert siblings back + if (hiddenChildren[active.id]?.length) { + newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + } + onDnd(newOrder); + }; + + /** + * Reset state if the drag was canceled + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + setCategoriesList(productCategories); + }; + + /** + * Hide children by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = findChildren(parentId); + if (children?.length) { + const tmpList = [...categoriesList]; + tmpList.splice(parentIndex + 1, children.length); + setCategoriesList(tmpList); + } + }; + + /** + * Find a category by its id + */ + const findCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + /** + * Find the children categories of a parent category by its id + */ + const findChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return hiddenChildren[id]; + }; + /** + * Find category's status by its id + * single | parent | child + */ + const categoryStatus = (id) => { + const c = findCategory(id); + if (!c.parent_id) { + if (findChildren(id)?.length) { + return 'parent'; + } + return 'single'; + } else { + return 'child'; + } + }; + + /** + * Translate visual order into categories data positions + */ + const indexToPosition = (sortedIds: number[]) => { + const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); + const newPositions = sort.map(c => { + if (typeof c.parent_id === 'number') { + const parentIndex = sort.findIndex(el => el.id === c.parent_id); + const currentIndex = sort.findIndex(el => el.id === c.id); + return { ...c, position: (currentIndex - parentIndex - 1) }; + } + return c; + }); + return newPositions; + }; + return ( -
- {productCategories.map((category) => ( -
-
-

{category.name}

- [count] -
-
-
- + +
+ {categoriesList + .map((category) => ( + - -
- } className='draghandle' /> -
+ category={category} + onSuccess={onSuccess} + onError={onError} + isChild={typeof category.parent_id === 'number'} + /> + ))}
- ))} -
+ + ); }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 0900f56fa..e7cae432f 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -5,6 +5,7 @@ import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; import { ProductCategoriesTree } from './product-categories-tree'; import { FabAlert } from '../../base/fab-alert'; +import { FabButton } from '../../base/fab-button'; import { HtmlTranslate } from '../../base/html-translate'; import { IApplication } from '../../../models/application'; import { Loader } from '../../base/loader'; @@ -41,28 +42,58 @@ const ProductCategories: React.FC = ({ onSuccess, onErro refreshCategories(); }; + /** + * Update state after drop + */ + const handleDnd = (data: ProductCategory[]) => { + setProductCategories(data); + }; + /** * Refresh the list of categories */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - setProductCategories(data); + // Translate ProductCategory.position to array index + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); }).catch((error) => onError(error)); }; + /** + * Save list's new order + */ + const handleSave = () => { + // TODO: index to position -> send to API + console.log('save order:', productCategories); + }; + return (

{t('app.admin.store.product_categories.title')}

- +
+ + Plop +
); diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 3aab16990..5aec87ae3 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -22,6 +22,16 @@ display: flex; justify-content: space-between; align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + .saveBtn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + } h2 { margin: 0; @include title-lg; @@ -40,8 +50,8 @@ } &-tree { - & > *:not(:last-of-type) { - margin-bottom: 1.6rem; + & > *:not(:first-child) { + margin-top: 1.6rem; } } &-item { @@ -94,4 +104,4 @@ cursor: grab; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index baadafef9..ff8113ae1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", diff --git a/yarn.lock b/yarn.lock index 1f2827b84..968221b91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,37 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -7452,6 +7483,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"