1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

(wip) drag and drop

This commit is contained in:
vincent 2022-08-01 16:17:21 +02:00 committed by Sylvain
parent debe8a6a52
commit ecb7f6d640
6 changed files with 356 additions and 147 deletions

View File

@ -1,14 +1,19 @@
// TODO: Remove next eslint-disable
/* eslint-disable @typescript-eslint/no-unused-vars */
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 { DotsSixVertical } from 'phosphor-react';
import { CaretDown, DotsSixVertical } from 'phosphor-react';
interface ProductCategoriesItemProps {
productCategories: Array<ProductCategory>,
category: ProductCategory,
isChild?: boolean,
offset: boolean,
collapsed?: boolean,
handleCollapse?: (id: number) => void,
status: 'child' | 'single' | 'parent',
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
@ -16,41 +21,53 @@ interface ProductCategoriesItemProps {
/**
* Renders a draggable category item
*/
export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ productCategories, category, isChild, onSuccess, onError }) => {
export const ProductCategoriesItem: React.FC<ProductCategoriesItemProps> = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition
transition,
isDragging
} = useSortable({ id: category.id });
const style = {
transform: CSS.Transform.toString(transform),
transition
transition,
transform: CSS.Transform.toString(transform)
};
return (
<div ref={setNodeRef} style={style} className={`product-categories-item ${isChild ? 'is-child' : ''}`}>
<div className='itemInfo'>
<p className='itemInfo-name'>{category.name}</p>
<span className='itemInfo-count'>[count]</span>
</div>
<div className='actions'>
<div className='manage'>
<ManageProductCategory action='update'
productCategories={productCategories}
productCategory={category}
onSuccess={onSuccess} onError={onError} />
<ManageProductCategory action='delete'
productCategories={productCategories}
productCategory={category}
onSuccess={onSuccess} onError={onError} />
<div ref={setNodeRef} style={style}
className={`product-categories-item ${(status === 'child' && collapsed) ? 'is-collapsed' : ''}`}>
{(status === 'child' || offset) &&
<div className='offset'></div>
}
<div className="wrap">
<div className='itemInfo'>
{status === 'parent' && <div className='collapse-handle'>
<button className={collapsed ? '' : 'rotate'} onClick={() => handleCollapse(category.id)}>
<CaretDown size={16} weight="bold" />
</button>
</div>}
<p className='itemInfo-name'>{category.name}</p>
<span className='itemInfo-count'>[count]</span>
</div>
<div>
<button {...attributes} {...listeners} className='draghandle'>
<DotsSixVertical size={16} />
</button>
<div className='actions'>
<div className='manage'>
<ManageProductCategory action='update'
productCategories={productCategories}
productCategory={category}
onSuccess={onSuccess} onError={onError} />
<ManageProductCategory action='delete'
productCategories={productCategories}
productCategory={category}
onSuccess={onSuccess} onError={onError} />
</div>
<div className='drag-handle'>
<button {...attributes} {...listeners}>
<DotsSixVertical size={16} />
</button>
</div>
</div>
</div>
</div>

View File

@ -1,9 +1,11 @@
// TODO: Remove next eslint-disable
/* 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 { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { restrictToWindowEdges } from '@dnd-kit/modifiers';
import { ProductCategoriesItem } from './product-categories-item';
interface ProductCategoriesTreeProps {
@ -18,13 +20,18 @@ interface ProductCategoriesTreeProps {
*/
export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ productCategories, onDnd, onSuccess, onError }) => {
const [categoriesList, setCategoriesList] = useImmer<ProductCategory[]>(productCategories);
const [hiddenChildren, setHiddenChildren] = useState({});
const [activeData, setActiveData] = useImmer<ActiveData>(initActiveData);
// TODO: type extractedChildren: {[parentId]: ProductCategory[]} ???
const [extractedChildren, setExtractedChildren] = useImmer({});
const [collapsed, setCollapsed] = useImmer<number[]>([]);
const [offset, setOffset] = useState<boolean>(false);
// Initialize state from props, sorting list as a tree
// Initialize state from props
useEffect(() => {
setCategoriesList(productCategories);
}, [productCategories]);
// Dnd Kit config
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
@ -34,145 +41,238 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
/**
* On drag start
* Collect dragged items' data
* Extract children from list
*/
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);
}
const activeIndex = active.data.current.sortable.index;
const children = getChildren(active.id);
setActiveData(draft => {
draft.index = activeIndex;
draft.category = getCategory(active.id);
draft.status = getStatus(active.id);
draft.children = children?.length ? children : null;
});
setExtractedChildren(draft => { draft[active.id] = children; });
hideChildren(active.id, activeIndex);
};
/**
* 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');
const handleDragMove = ({ delta, active, over }: DragMoveEvent) => {
if ((getStatus(active.id) === 'single' || getStatus(active.id) === 'child') && getStatus(over.id) === 'single') {
if (delta.x > 32) {
setOffset(true);
} else {
setOffset(false);
}
}
};
/**
* Update categories list after an item was dropped
* On drag End
* Insert children back in list
*/
const handleDragEnd = ({ active, over }: DragMoveEvent) => {
let newOrder = [...categoriesList];
const currentIdsOrder = over?.data.current.sortable.items;
let newIndex = over.data.current.sortable.index;
// 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
// [A] Single |> [B] Single
if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') {
console.log('[A] Single |> [B] Single');
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
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;
let category = getCategory(sortedId);
if (offset && sortedId === active.id && activeData.index < newIndex) {
category = { ...category, parent_id: Number(over.id) };
}
// retour de la catégorie courante
return categoryFromId;
return category;
});
}
// 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 });
// [A] Child |> [B] Single
if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') {
console.log('[A] Child |> [B] Single');
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (offset && sortedId === active.id && activeData.index < newIndex) {
category = { ...category, parent_id: Number(over.id) };
} else if (sortedId === active.id && activeData.index < newIndex) {
category = { ...category, parent_id: null };
}
return category;
});
}
// [A] Single || Child |>…
if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') {
// [B] Parent
if (getStatus(over.id) === 'parent') {
if (activeData.index < newIndex) {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (sortedId === active.id) {
category = { ...category, parent_id: Number(over.id) };
}
return category;
});
} else {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (sortedId === active.id) {
category = { ...category, parent_id: null };
}
return category;
});
}
}
// [B] Child
if (getStatus(over.id) === 'child') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => {
let category = getCategory(sortedId);
if (sortedId === active.id) {
category = { ...category, parent_id: getCategory(over.id).parent_id };
}
return category;
});
}
}
// [A] Parent |>…
if (getStatus(active.id) === 'parent') {
// [B] Single
if (getStatus(over.id) === 'single') {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
// [B] Parent
if (getStatus(over.id) === 'parent') {
if (activeData.index < newIndex) {
const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id);
newIndex = lastOverChildIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
} else {
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
}
// [B] Child
if (getStatus(over.id) === 'child') {
if (activeData.index < newIndex) {
const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id);
const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id);
newIndex = lastSiblingIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
} else {
const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id);
newIndex = parentIndex;
const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex);
newOrder = newIdsOrder.map(sortedId => getCategory(sortedId));
}
}
// insert children back
newOrder = showChildren(active.id, newOrder, newIndex);
}
onDnd(newOrder);
setOffset(false);
};
/**
* Reset state if the drag was canceled
* On drag cancel
* Reset states
*/
const handleDragCancel = ({ active }: DragMoveEvent) => {
setHiddenChildren({ ...hiddenChildren, [active.id]: null });
setCategoriesList(productCategories);
setActiveData(initActiveData);
setExtractedChildren({ ...extractedChildren, [active.id]: null });
};
/**
* Hide children by their parent's id
* Get a category by its 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) => {
const getCategory = (id) => {
return categoriesList.find(c => c.id === id);
};
/**
* Find the children categories of a parent category by its id
* Get the children categories of a parent category by its id
*/
const findChildren = (id) => {
const getChildren = (id) => {
const displayedChildren = categoriesList.filter(c => c.parent_id === id);
if (displayedChildren.length) {
return displayedChildren;
}
return hiddenChildren[id];
return extractedChildren[id];
};
/**
* Find category's status by its id
* single | parent | child
* Get category's status by its id
* child | single | parent
*/
const categoryStatus = (id) => {
const c = findCategory(id);
if (!c.parent_id) {
if (findChildren(id)?.length) {
return 'parent';
}
return 'single';
} else {
return 'child';
const getStatus = (id) => {
const c = getCategory(id);
return !c.parent_id
? getChildren(id)?.length
? 'parent'
: 'single'
: 'child';
};
/**
* Extract children from the list by their parent's id
*/
const hideChildren = (parentId, parentIndex) => {
const children = getChildren(parentId);
if (children?.length) {
const shortenList = [...categoriesList];
shortenList.splice(parentIndex + 1, children.length);
setCategoriesList(shortenList);
}
};
/**
* Translate visual order into categories data positions
* Insert children back in the list by their parent's id
*/
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;
const showChildren = (parentId, currentList, insertIndex) => {
if (extractedChildren[parentId]?.length) {
currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]);
setExtractedChildren({ ...extractedChildren, [parentId]: null });
}
return currentList;
};
/**
* Toggle parent category by hidding/showing its children
*/
const handleCollapse = (id) => {
const i = collapsed.findIndex(el => el === id);
if (i === -1) {
setCollapsed([...collapsed, id]);
} else {
const copy = [...collapsed];
copy.splice(i, 1);
setCollapsed(copy);
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToWindowEdges]}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
@ -185,7 +285,10 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
category={category}
onSuccess={onSuccess}
onError={onError}
isChild={typeof category.parent_id === 'number'}
offset={category.id === activeData.category?.id && activeData?.offset}
collapsed={collapsed.includes(category.id) || collapsed.includes(category.parent_id)}
handleCollapse={handleCollapse}
status={getStatus(category.id)}
/>
))}
</div>
@ -193,3 +296,18 @@ export const ProductCategoriesTree: React.FC<ProductCategoriesTreeProps> = ({ pr
</DndContext>
);
};
interface ActiveData {
index: number,
category: ProductCategory,
status: 'child' | 'single' | 'parent',
children: ProductCategory[],
offset: boolean
}
const initActiveData: ActiveData = {
index: null,
category: null,
status: null,
children: [],
offset: false
};

View File

@ -0,0 +1,35 @@
<!-- Drop options -->
## [A] Single |> [B] Single
[A] = index de [B]
offset && [A] child de [B]
<!--## [A] Single || Child |> [B] Parent
[A] = index de [B]
[A] child de [B]-->
<!--## [A] Single || Child |> [B] Child
[A] = index de [B]
[A] même parent que [B]-->
## [A] Child |> [B] Single
[A] = index de [B]
offset
? [A] child de [B]
: [A] Single
<!--## [A] Parent |> [B] Single
[A] = index de [B]-->
<!--## [A] Parent |> [B] Parent
down
? [A] = index du dernier child de [B]
: [A] = index de [B]-->
<!--## [A] Parent |> [B] Child
down
? [A] = index du dernier child de [B]
: [A] = index du parent de [B]-->
## [A] Single |> [A]
offset && [A] child du précédant parent

View File

@ -41,50 +41,80 @@
}
&-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 1.6rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
pointer-events: all;
.itemInfo {
display: flex;
justify-content: flex-end;
align-items: center;
&-name {
margin: 0;
@include text-base;
font-weight: 600;
color: var(--gray-hard-darkest);
}
&-count {
margin-left: 2.4rem;
@include text-sm;
font-weight: 500;
color: var(--information);
}
&.is-collapsed {
height: 0;
margin: 0;
padding: 0;
border: none;
overflow: hidden;
pointer-events: none;
}
.offset {
width: 4.8rem;
}
.actions {
.wrap {
width: 100%;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
.manage {
overflow: hidden;
padding: 0.6rem 1.6rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
.itemInfo {
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
align-items: center;
&-name {
margin: 0;
@include text-base;
font-weight: 600;
color: var(--gray-hard-darkest);
}
&-count {
margin-left: 2.4rem;
@include text-sm;
font-weight: 500;
color: var(--information);
}
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
.manage {
overflow: hidden;
display: flex;
border-radius: var(--border-radius-sm);
button {
@include btn;
border-radius: 0;
color: var(--gray-soft-lightest);
&:hover { opacity: 0.75; }
}
.edit-btn { background: var(--gray-hard-darkest); }
.delete-btn { background: var(--error); }
}
.edit-btn {background: var(--gray-hard-darkest) }
.delete-btn {background: var(--error) }
}
}
.draghandle {
.collapse-handle {
width: 4rem;
margin: 0 0 0 -1rem;
button {
@include btn;
background: none;
border-radius: 0;
transition: transform 250ms ease-in-out;
&.rotate {
transform: rotateZ(-180deg);
}
}
}
.drag-handle button {
@include btn;
cursor: grab;
}

View File

@ -48,6 +48,7 @@
"@babel/runtime": "^7.17.2",
"@claviska/jquery-minicolors": "^2.3.5",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/modifiers": "^6.0.0",
"@dnd-kit/sortable": "^7.0.1",
"@fortawesome/fontawesome-free": "5.14.0",
"@lyracom/embedded-form-glue": "^0.3.3",

View File

@ -1504,6 +1504,14 @@
"@dnd-kit/utilities" "^3.2.0"
tslib "^2.0.0"
"@dnd-kit/modifiers@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078"
integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q==
dependencies:
"@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"