From 8c784a8b30930f4e4d0b4c113f68ef879a25aceb Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Sun, 18 Aug 2024 22:21:03 +0200 Subject: [PATCH] fix(dashboard): Replace `react-nestable` with new SortableTree component (#8599) **What** - Removes `react-nestable` dependency in favour of our own solution based on `@dnd-kit/core` Resolves CC-217 --- packages/admin-next/dashboard/package.json | 1 - .../components/common/sortable-tree/index.ts | 1 + .../sortable-tree/keyboard-coordinates.ts | 156 +++++++ .../sortable-tree/sortable-tree-item.tsx | 62 +++ .../common/sortable-tree/sortable-tree.tsx | 379 ++++++++++++++++++ .../common/sortable-tree/tree-item.tsx | 207 ++++++++++ .../components/common/sortable-tree/types.ts | 23 ++ .../components/common/sortable-tree/utils.ts | 299 ++++++++++++++ .../data-grid-root/data-grid-root.tsx | 1 - .../create-category-form.tsx | 91 +++-- .../create-category-nesting.tsx | 57 ++- .../organize-category-form.tsx | 36 +- .../category-tree/category-tree.tsx | 206 +--------- .../components/category-tree/styles.css | 50 --- yarn.lock | 32 -- 15 files changed, 1267 insertions(+), 334 deletions(-) create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/index.ts create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/keyboard-coordinates.ts create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree-item.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/tree-item.tsx create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/types.ts create mode 100644 packages/admin-next/dashboard/src/components/common/sortable-tree/utils.ts delete mode 100644 packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index ff359ffed1fd1..c325eaa515683 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -58,7 +58,6 @@ "react-hook-form": "7.49.1", "react-i18next": "13.5.0", "react-jwt": "^1.2.0", - "react-nestable": "^3.0.2", "react-router-dom": "6.20.1", "zod": "3.22.4" }, diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/index.ts b/packages/admin-next/dashboard/src/components/common/sortable-tree/index.ts new file mode 100644 index 0000000000000..46f2ac3cb4c4b --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/index.ts @@ -0,0 +1 @@ +export * from "./sortable-tree" diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/keyboard-coordinates.ts b/packages/admin-next/dashboard/src/components/common/sortable-tree/keyboard-coordinates.ts new file mode 100644 index 0000000000000..a836cc5a03e61 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/keyboard-coordinates.ts @@ -0,0 +1,156 @@ +import { + DroppableContainer, + KeyboardCode, + KeyboardCoordinateGetter, + closestCorners, + getFirstCollision, +} from "@dnd-kit/core" + +import type { SensorContext } from "./types" +import { getProjection } from "./utils" + +const directions: string[] = [ + KeyboardCode.Down, + KeyboardCode.Right, + KeyboardCode.Up, + KeyboardCode.Left, +] + +const horizontal: string[] = [KeyboardCode.Left, KeyboardCode.Right] + +export const sortableTreeKeyboardCoordinates: ( + context: SensorContext, + indentationWidth: number +) => KeyboardCoordinateGetter = + (context, indentationWidth) => + ( + event, + { + currentCoordinates, + context: { + active, + over, + collisionRect, + droppableRects, + droppableContainers, + }, + } + ) => { + if (directions.includes(event.code)) { + if (!active || !collisionRect) { + return + } + + event.preventDefault() + + const { + current: { items, offset }, + } = context + + if (horizontal.includes(event.code) && over?.id) { + const { depth, maxDepth, minDepth } = getProjection( + items, + active.id, + over.id, + offset, + indentationWidth + ) + + switch (event.code) { + case KeyboardCode.Left: + if (depth > minDepth) { + return { + ...currentCoordinates, + x: currentCoordinates.x - indentationWidth, + } + } + break + case KeyboardCode.Right: + if (depth < maxDepth) { + return { + ...currentCoordinates, + x: currentCoordinates.x + indentationWidth, + } + } + break + } + + return undefined + } + + const containers: DroppableContainer[] = [] + + droppableContainers.forEach((container) => { + if (container?.disabled || container.id === over?.id) { + return + } + + const rect = droppableRects.get(container.id) + + if (!rect) { + return + } + + switch (event.code) { + case KeyboardCode.Down: + if (collisionRect.top < rect.top) { + containers.push(container) + } + break + case KeyboardCode.Up: + if (collisionRect.top > rect.top) { + containers.push(container) + } + break + } + }) + + const collisions = closestCorners({ + active, + collisionRect, + pointerCoordinates: null, + droppableRects, + droppableContainers: containers, + }) + let closestId = getFirstCollision(collisions, "id") + + if (closestId === over?.id && collisions.length > 1) { + closestId = collisions[1].id + } + + if (closestId && over?.id) { + const activeRect = droppableRects.get(active.id) + const newRect = droppableRects.get(closestId) + const newDroppable = droppableContainers.get(closestId) + + if (activeRect && newRect && newDroppable) { + const newIndex = items.findIndex(({ id }) => id === closestId) + const newItem = items[newIndex] + const activeIndex = items.findIndex(({ id }) => id === active.id) + const activeItem = items[activeIndex] + + if (newItem && activeItem) { + const { depth } = getProjection( + items, + active.id, + closestId, + (newItem.depth - activeItem.depth) * indentationWidth, + indentationWidth + ) + const isBelow = newIndex > activeIndex + const modifier = isBelow ? 1 : -1 + const offset = 0 + + const newCoordinates = { + x: newRect.left + depth * indentationWidth, + y: newRect.top + modifier * offset, + } + + return newCoordinates + } + } + } + } + + return undefined + } diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree-item.tsx b/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree-item.tsx new file mode 100644 index 0000000000000..d87821a9117e6 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree-item.tsx @@ -0,0 +1,62 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" +import { AnimateLayoutChanges, useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { CSSProperties } from "react" + +import { TreeItem, TreeItemProps } from "./tree-item" +import { iOS } from "./utils" + +interface SortableTreeItemProps extends TreeItemProps { + id: UniqueIdentifier +} + +const animateLayoutChanges: AnimateLayoutChanges = ({ + isSorting, + wasDragging, +}) => { + return isSorting || wasDragging ? false : true +} + +export function SortableTreeItem({ + id, + depth, + disabled, + ...props +}: SortableTreeItemProps) { + const { + attributes, + isDragging, + isSorting, + listeners, + setDraggableNodeRef, + setDroppableNodeRef, + transform, + transition, + } = useSortable({ + id, + animateLayoutChanges, + disabled, + }) + const style: CSSProperties = { + transform: CSS.Translate.toString(transform), + transition, + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree.tsx b/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree.tsx new file mode 100644 index 0000000000000..9ddeb8962a303 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/sortable-tree.tsx @@ -0,0 +1,379 @@ +import { + Announcements, + DndContext, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + DropAnimation, + KeyboardSensor, + MeasuringStrategy, + PointerSensor, + UniqueIdentifier, + closestCenter, + defaultDropAnimation, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { createPortal } from "react-dom" + +import { sortableTreeKeyboardCoordinates } from "./keyboard-coordinates" +import { SortableTreeItem } from "./sortable-tree-item" +import type { FlattenedItem, SensorContext, TreeItem } from "./types" +import { + buildTree, + flattenTree, + getChildCount, + getProjection, + removeChildrenOf, +} from "./utils" + +const measuring = { + droppable: { + strategy: MeasuringStrategy.Always, + }, +} + +const dropAnimationConfig: DropAnimation = { + keyframes({ transform }) { + return [ + { opacity: 1, transform: CSS.Transform.toString(transform.initial) }, + { + opacity: 0, + transform: CSS.Transform.toString({ + ...transform.final, + x: transform.final.x + 5, + y: transform.final.y + 5, + }), + }, + ] + }, + easing: "ease-out", + sideEffects({ active }) { + active.node.animate([{ opacity: 0 }, { opacity: 1 }], { + duration: defaultDropAnimation.duration, + easing: defaultDropAnimation.easing, + }) + }, +} + +interface Props { + collapsible?: boolean + childrenProp?: string + items: T[] + indentationWidth?: number + /** + * Enable drag for all items or provide a function to enable drag for specific items. + * @default true + */ + enableDrag?: boolean | ((item: T) => boolean) + onChange: ( + updatedItem: { + id: UniqueIdentifier + parentId: UniqueIdentifier | null + index: number + }, + items: T[] + ) => void + renderValue: (item: T) => ReactNode +} + +export function SortableTree({ + collapsible = true, + childrenProp = "children", // "children" is the default children prop name + enableDrag = true, + items = [], + indentationWidth = 40, + onChange, + renderValue, +}: Props) { + const [collapsedState, setCollapsedState] = useState< + Record + >({}) + + const [activeId, setActiveId] = useState(null) + const [overId, setOverId] = useState(null) + const [offsetLeft, setOffsetLeft] = useState(0) + const [currentPosition, setCurrentPosition] = useState<{ + parentId: UniqueIdentifier | null + overId: UniqueIdentifier + } | null>(null) + + const flattenedItems = useMemo(() => { + const flattenedTree = flattenTree(items, childrenProp) + const collapsedItems = flattenedTree.reduce( + (acc, item) => { + const { id } = item + const children = (item[childrenProp] || []) as FlattenedItem[] + const collapsed = collapsedState[id] + + return collapsed && children.length ? [...acc, id] : acc + }, + [] + ) + + return removeChildrenOf( + flattenedTree, + activeId ? [activeId, ...collapsedItems] : collapsedItems, + childrenProp + ) + }, [activeId, items, childrenProp, collapsedState]) + + const projected = + activeId && overId + ? getProjection( + flattenedItems, + activeId, + overId, + offsetLeft, + indentationWidth + ) + : null + + const sensorContext: SensorContext = useRef({ + items: flattenedItems, + offset: offsetLeft, + }) + + const [coordinateGetter] = useState(() => + sortableTreeKeyboardCoordinates(sensorContext, indentationWidth) + ) + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter, + }) + ) + + const sortedIds = useMemo( + () => flattenedItems.map(({ id }) => id), + [flattenedItems] + ) + + const activeItem = activeId + ? flattenedItems.find(({ id }) => id === activeId) + : null + + useEffect(() => { + sensorContext.current = { + items: flattenedItems, + offset: offsetLeft, + } + }, [flattenedItems, offsetLeft]) + + function handleDragStart({ active: { id: activeId } }: DragStartEvent) { + setActiveId(activeId) + setOverId(activeId) + + const activeItem = flattenedItems.find(({ id }) => id === activeId) + + if (activeItem) { + setCurrentPosition({ + parentId: activeItem.parentId, + overId: activeId, + }) + } + + document.body.style.setProperty("cursor", "grabbing") + } + + function handleDragMove({ delta }: DragMoveEvent) { + setOffsetLeft(delta.x) + } + + function handleDragOver({ over }: DragOverEvent) { + setOverId(over?.id ?? null) + } + + function handleDragEnd({ active, over }: DragEndEvent) { + resetState() + + if (projected && over) { + const { depth, parentId } = projected + const clonedItems: FlattenedItem[] = JSON.parse( + JSON.stringify(flattenTree(items, childrenProp)) + ) + const overIndex = clonedItems.findIndex(({ id }) => id === over.id) + + const activeIndex = clonedItems.findIndex(({ id }) => id === active.id) + const activeTreeItem = clonedItems[activeIndex] + + clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId } + + const sortedItems = arrayMove(clonedItems, activeIndex, overIndex) + + const { items: newItems, update } = buildTree( + sortedItems, + overIndex, + childrenProp + ) + + onChange(update, newItems) + } + } + + function handleDragCancel() { + resetState() + } + + function resetState() { + setOverId(null) + setActiveId(null) + setOffsetLeft(0) + setCurrentPosition(null) + + document.body.style.setProperty("cursor", "") + } + + function handleCollapse(id: UniqueIdentifier) { + setCollapsedState((state) => ({ + ...state, + [id]: state[id] ? false : true, + })) + } + + function getMovementAnnouncement( + eventName: string, + activeId: UniqueIdentifier, + overId?: UniqueIdentifier + ) { + if (overId && projected) { + if (eventName !== "onDragEnd") { + if ( + currentPosition && + projected.parentId === currentPosition.parentId && + overId === currentPosition.overId + ) { + return + } else { + setCurrentPosition({ + parentId: projected.parentId, + overId, + }) + } + } + + const clonedItems: FlattenedItem[] = JSON.parse( + JSON.stringify(flattenTree(items, childrenProp)) + ) + const overIndex = clonedItems.findIndex(({ id }) => id === overId) + const activeIndex = clonedItems.findIndex(({ id }) => id === activeId) + const sortedItems = arrayMove(clonedItems, activeIndex, overIndex) + + const previousItem = sortedItems[overIndex - 1] + + let announcement + const movedVerb = eventName === "onDragEnd" ? "dropped" : "moved" + const nestedVerb = eventName === "onDragEnd" ? "dropped" : "nested" + + if (!previousItem) { + const nextItem = sortedItems[overIndex + 1] + announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.` + } else { + if (projected.depth > previousItem.depth) { + announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.` + } else { + let previousSibling: FlattenedItem | undefined = previousItem + while (previousSibling && projected.depth < previousSibling.depth) { + const parentId: UniqueIdentifier | null = previousSibling.parentId + previousSibling = sortedItems.find(({ id }) => id === parentId) + } + + if (previousSibling) { + announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.` + } + } + } + + return announcement + } + + return + } + + const announcements: Announcements = { + onDragStart({ active }) { + return `Picked up ${active.id}.` + }, + onDragMove({ active, over }) { + return getMovementAnnouncement("onDragMove", active.id, over?.id) + }, + onDragOver({ active, over }) { + return getMovementAnnouncement("onDragOver", active.id, over?.id) + }, + onDragEnd({ active, over }) { + return getMovementAnnouncement("onDragEnd", active.id, over?.id) + }, + onDragCancel({ active }) { + return `Moving was cancelled. ${active.id} was dropped in its original position.` + }, + } + + return ( + + + {flattenedItems.map((item) => { + const { id, depth } = item + const children = (item[childrenProp] || []) as FlattenedItem[] + + const disabled = + typeof enableDrag === "function" + ? !enableDrag(item as unknown as T) + : !enableDrag + + return ( + handleCollapse(id) + : undefined + } + /> + ) + })} + {createPortal( + + {activeId && activeItem ? ( + + ) : null} + , + document.body + )} + + + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/tree-item.tsx b/packages/admin-next/dashboard/src/components/common/sortable-tree/tree-item.tsx new file mode 100644 index 0000000000000..1e455df24629d --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/tree-item.tsx @@ -0,0 +1,207 @@ +import React, { forwardRef, HTMLAttributes, ReactNode } from "react" + +import { + DotsSix, + FolderIllustration, + FolderOpenIllustration, + Swatch, + TriangleRightMini, +} from "@medusajs/icons" +import { Badge, clx, IconButton } from "@medusajs/ui" +import { HandleProps } from "./types" + +export interface TreeItemProps + extends Omit, "id"> { + childCount?: number + clone?: boolean + collapsed?: boolean + depth: number + disableInteraction?: boolean + disableSelection?: boolean + ghost?: boolean + handleProps?: HandleProps + indentationWidth: number + value: ReactNode + disabled?: boolean + onCollapse?(): void + wrapperRef?(node: HTMLLIElement): void +} + +export const TreeItem = forwardRef( + ( + { + childCount, + clone, + depth, + disableSelection, + disableInteraction, + ghost, + handleProps, + indentationWidth, + collapsed, + onCollapse, + style, + value, + disabled, + wrapperRef, + ...props + }, + ref + ) => { + return ( +
  • div]:border-t-0": !clone, + })} + {...props} + > +
    0, + "shadow-elevation-flyout bg-ui-bg-base w-fit rounded-lg border-none pr-6 opacity-80": + clone, + "bg-ui-bg-base-hover z-[1] opacity-50": ghost, + "bg-ui-bg-disabled": disabled, + } + )} + > + + + + + +
    +
  • + ) + } +) +TreeItem.displayName = "TreeItem" + +const Handle = ({ + listeners, + attributes, + disabled, +}: HandleProps & { disabled?: boolean }) => { + return ( + + + + ) +} + +type IconProps = { + childrenCount?: number + collapsed?: boolean + clone?: boolean +} + +const Icon = ({ childrenCount, collapsed, clone }: IconProps) => { + const isBranch = clone ? childrenCount && childrenCount > 1 : childrenCount + const isOpen = clone ? false : !collapsed + + return ( +
    + {isBranch ? ( + isOpen ? ( + + ) : ( + + ) + ) : ( + + )} +
    + ) +} + +type CollapseProps = { + collapsed?: boolean + onCollapse?: () => void + clone?: boolean +} + +const Collapse = ({ collapsed, onCollapse, clone }: CollapseProps) => { + if (clone) { + return null + } + + if (!onCollapse) { + return
    + } + + return ( + + + + ) +} + +type ValueProps = { + value: ReactNode +} + +const Value = ({ value }: ValueProps) => { + return ( +
    + {value} +
    + ) +} + +type ChildrenCountProps = { + clone?: boolean + childrenCount?: number +} + +const ChildrenCount = ({ clone, childrenCount }: ChildrenCountProps) => { + if (!clone || !childrenCount) { + return null + } + + if (clone && childrenCount <= 1) { + return null + } + + return ( + + {childrenCount} + + ) +} diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/types.ts b/packages/admin-next/dashboard/src/components/common/sortable-tree/types.ts new file mode 100644 index 0000000000000..8b78d701d1450 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/types.ts @@ -0,0 +1,23 @@ +import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core" +import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities" +import type { MutableRefObject } from "react" + +export interface TreeItem extends Record { + id: UniqueIdentifier +} + +export interface FlattenedItem extends TreeItem { + parentId: UniqueIdentifier | null + depth: number + index: number +} + +export type SensorContext = MutableRefObject<{ + items: FlattenedItem[] + offset: number +}> + +export type HandleProps = { + attributes?: DraggableAttributes | undefined + listeners?: SyntheticListenerMap | undefined +} diff --git a/packages/admin-next/dashboard/src/components/common/sortable-tree/utils.ts b/packages/admin-next/dashboard/src/components/common/sortable-tree/utils.ts new file mode 100644 index 0000000000000..064546dcdfda0 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/sortable-tree/utils.ts @@ -0,0 +1,299 @@ +import type { UniqueIdentifier } from "@dnd-kit/core" +import { arrayMove } from "@dnd-kit/sortable" + +import type { FlattenedItem, TreeItem } from "./types" + +export const iOS = /iPad|iPhone|iPod/.test(navigator.platform) + +function getDragDepth(offset: number, indentationWidth: number) { + return Math.round(offset / indentationWidth) +} + +export function getProjection( + items: FlattenedItem[], + activeId: UniqueIdentifier, + overId: UniqueIdentifier, + dragOffset: number, + indentationWidth: number +) { + const overItemIndex = items.findIndex(({ id }) => id === overId) + const activeItemIndex = items.findIndex(({ id }) => id === activeId) + const activeItem = items[activeItemIndex] + const newItems = arrayMove(items, activeItemIndex, overItemIndex) + const previousItem = newItems[overItemIndex - 1] + const nextItem = newItems[overItemIndex + 1] + const dragDepth = getDragDepth(dragOffset, indentationWidth) + const projectedDepth = activeItem.depth + dragDepth + const maxDepth = getMaxDepth({ + previousItem, + }) + const minDepth = getMinDepth({ nextItem }) + let depth = projectedDepth + + if (projectedDepth >= maxDepth) { + depth = maxDepth + } else if (projectedDepth < minDepth) { + depth = minDepth + } + + return { depth, maxDepth, minDepth, parentId: getParentId() } + + function getParentId() { + if (depth === 0 || !previousItem) { + return null + } + + if (depth === previousItem.depth) { + return previousItem.parentId + } + + if (depth > previousItem.depth) { + return previousItem.id + } + + const newParent = newItems + .slice(0, overItemIndex) + .reverse() + .find((item) => item.depth === depth)?.parentId + + return newParent ?? null + } +} + +function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) { + if (previousItem) { + return previousItem.depth + 1 + } + + return 0 +} + +function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) { + if (nextItem) { + return nextItem.depth + } + + return 0 +} + +function flatten( + items: T[], + parentId: UniqueIdentifier | null = null, + depth = 0, + childrenProp: string +): FlattenedItem[] { + return items.reduce((acc, item, index) => { + const children = (item[childrenProp] || []) as T[] + + return [ + ...acc, + { ...item, parentId, depth, index }, + ...flatten(children, item.id, depth + 1, childrenProp), + ] + }, []) +} + +export function flattenTree( + items: T[], + childrenProp: string +): FlattenedItem[] { + return flatten(items, undefined, undefined, childrenProp) +} + +type ItemUpdate = { + id: UniqueIdentifier + parentId: UniqueIdentifier | null + index: number +} + +export function buildTree( + flattenedItems: FlattenedItem[], + newIndex: number, + childrenProp: string +): { items: T[]; update: ItemUpdate } { + const root = { id: "root", [childrenProp]: [] } as T + const nodes: Record = { [root.id]: root } + const items = flattenedItems.map((item) => ({ ...item, [childrenProp]: [] })) + + let update: { + id: UniqueIdentifier | null + parentId: UniqueIdentifier | null + index: number + } = { + id: null, + parentId: null, + index: 0, + } + + items.forEach((item, index) => { + const { + id, + index: _index, + depth: _depth, + parentId: _parentId, + ...rest + } = item + const children = (item[childrenProp] || []) as T[] + + const parentId = _parentId ?? root.id + const parent = nodes[parentId] ?? findItem(items, parentId) + + nodes[id] = { id, [childrenProp]: children } as T + ;(parent[childrenProp] as T[]).push({ + id, + ...rest, + [childrenProp]: children, + } as T) + + /** + * Get the information for them item that was moved to the `newIndex`. + */ + if (index === newIndex) { + const parentChildren = parent[childrenProp] as FlattenedItem[] + + update = { + id: item.id, + parentId: parent.id === "root" ? null : parent.id, + index: parentChildren.length - 1, + } + } + }) + + if (!update.id) { + throw new Error("Could not find item") + } + + return { + items: root[childrenProp] as T[], + update: update as ItemUpdate, + } +} + +export function findItem( + items: T[], + itemId: UniqueIdentifier +) { + return items.find(({ id }) => id === itemId) +} + +export function findItemDeep( + items: T[], + itemId: UniqueIdentifier, + childrenProp: string +): TreeItem | undefined { + for (const item of items) { + const { id } = item + const children = (item[childrenProp] || []) as T[] + + if (id === itemId) { + return item + } + + if (children.length) { + const child = findItemDeep(children, itemId, childrenProp) + + if (child) { + return child + } + } + } + + return undefined +} + +export function setProperty( + items: TItem[], + id: UniqueIdentifier, + property: T, + childrenProp: keyof TItem, // Make childrenProp a key of TItem + setter: (value: TItem[T]) => TItem[T] +): TItem[] { + return items.map((item) => { + if (item.id === id) { + return { + ...item, + [property]: setter(item[property]), + } + } + + const children = item[childrenProp] as TItem[] | undefined + + if (children && children.length) { + return { + ...item, + [childrenProp]: setProperty( + children, + id, + property, + childrenProp, + setter + ), + } as TItem // Explicitly cast to TItem + } + + return item + }) +} + +function countChildren( + items: T[], + count = 0, + childrenProp: string +): number { + return items.reduce((acc, item) => { + const children = (item[childrenProp] || []) as T[] + + if (children.length) { + return countChildren(children, acc + 1, childrenProp) + } + + return acc + 1 + }, count) +} + +export function getChildCount( + items: T[], + id: UniqueIdentifier, + childrenProp: string +) { + const item = findItemDeep(items, id, childrenProp) + + const children = (item?.[childrenProp] || []) as T[] + + return item ? countChildren(children, 0, childrenProp) : 0 +} + +export function removeChildrenOf( + items: FlattenedItem[], + ids: UniqueIdentifier[], + childrenProp: string +) { + const excludeParentIds = [...ids] + + return items.filter((item) => { + if (item.parentId && excludeParentIds.includes(item.parentId)) { + const children = (item[childrenProp] || []) as FlattenedItem[] + + if (children.length) { + excludeParentIds.push(item.id) + } + return false + } + + return true + }) +} + +export function listItemsWithChildren( + items: T[], + childrenProp: string +): T[] { + return items.map((item) => { + return { + ...item, + [childrenProp]: item[childrenProp] + ? listItemsWithChildren(item[childrenProp] as TreeItem[], childrenProp) + : [], + } + }) +} diff --git a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx index 4d23893e3cf59..997cbf6d56081 100644 --- a/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx +++ b/packages/admin-next/dashboard/src/components/data-grid/data-grid-root/data-grid-root.tsx @@ -732,7 +732,6 @@ export const DataGridRoot = < } if (e.key === "z" && (e.metaKey || e.ctrlKey)) { - console.log("Undo/Redo") handleUndo(e) return } diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx index cf358eeb5541a..9abe45f56d095 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-form.tsx @@ -121,11 +121,15 @@ export const CreateCategoryForm = ({ return ( - handleTabChange(tab as Tab)} +
    - + handleTabChange(tab as Tab)} + className="flex size-full flex-col" + >
    @@ -133,57 +137,68 @@ export const CreateCategoryForm = ({ - {t("categories.create.tabs.details")} + + {t("categories.create.tabs.details")} + - {t("categories.create.tabs.organize")} + + {t("categories.create.tabs.organize")} +
    -
    - - - - {activeTab === Tab.ORGANIZE ? ( - - ) : ( - - )} -
    - + - + - -
    + +
    + + + + {activeTab === Tab.ORGANIZE ? ( + + ) : ( + + )} +
    +
    +
    +
    ) } diff --git a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx index de79dae30ee12..93fa4a393d38b 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-create/components/create-category-form/create-category-nesting.tsx @@ -1,5 +1,9 @@ +import { UniqueIdentifier } from "@dnd-kit/core" +import { Badge } from "@medusajs/ui" import { useMemo, useState } from "react" import { UseFormReturn, useWatch } from "react-hook-form" + +import { useTranslation } from "react-i18next" import { useProductCategories } from "../../../../../hooks/api/categories" import { CategoryTree } from "../../../common/components/category-tree" import { CategoryTreeItem } from "../../../common/types" @@ -17,20 +21,16 @@ export const CreateCategoryNesting = ({ form, shouldFreeze, }: CreateCategoryNestingProps) => { + const { t } = useTranslation() const [snapshot, setSnapshot] = useState([]) const { product_categories, isPending, isError, error } = - useProductCategories( - { - parent_category_id: "null", - limit: 9999, - fields: "id,name,parent_category_id,rank,category_children", - include_descendants_tree: true, - }, - { - refetchInterval: Infinity, // Once the data is loaded we don't need to refetch - } - ) + useProductCategories({ + parent_category_id: "null", + limit: 9999, + fields: "id,name,parent_category_id,rank,category_children,rank", + include_descendants_tree: true, + }) const parentCategoryId = useWatch({ control: form.control, @@ -60,15 +60,22 @@ export const CreateCategoryNesting = ({ }, [product_categories, watchedName, parentCategoryId, watchedRank]) const handleChange = ( - { parent_category_id, rank }: CategoryTreeItem, + { + parentId, + index, + }: { + id: UniqueIdentifier + parentId: UniqueIdentifier | null + index: number + }, list: CategoryTreeItem[] ) => { - form.setValue("parent_category_id", parent_category_id, { + form.setValue("parent_category_id", parentId as string | null, { shouldDirty: true, shouldTouch: true, }) - form.setValue("rank", rank, { + form.setValue("rank", index, { shouldDirty: true, shouldTouch: true, }) @@ -80,14 +87,28 @@ export const CreateCategoryNesting = ({ throw error } + const ready = !isPending && !!product_categories + return ( item.id === ID} onChange={handleChange} - enableDrag={(i) => i.id === ID} - showBadge={(i) => i.id === ID} - isLoading={isPending || !product_categories} + renderValue={(item) => { + if (item.id === ID) { + return ( +
    + {item.name} + + {t("categories.fields.new.label")} + +
    + ) + } + + return item.name + }} + isLoading={!ready} /> ) } diff --git a/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx b/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx index 3c739bf1b7ad6..097ddd15790ae 100644 --- a/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/category-organize/components/organize-category-form/organize-category-form.tsx @@ -1,9 +1,11 @@ import { useMutation } from "@tanstack/react-query" +import { UniqueIdentifier } from "@dnd-kit/core" import { Spinner } from "@medusajs/icons" import { FetchError } from "@medusajs/js-sdk" import { HttpTypes } from "@medusajs/types" import { toast } from "@medusajs/ui" +import { useState } from "react" import { RouteFocusModal } from "../../../../../components/modals" import { categoriesQueryKeys, @@ -29,11 +31,17 @@ export const OrganizeCategoryForm = () => { error: fetchError, } = useProductCategories(QUERY) + const [snapshot, setSnapshot] = useState([]) + const { mutateAsync, isPending: isMutating } = useMutation({ mutationFn: async ({ value, }: { - value: CategoryTreeItem + value: { + id: string + parent_category_id: string | null + rank: number | null + } arr: CategoryTreeItem[] }) => { await sdk.admin.productCategory.update(value.id, { @@ -57,6 +65,7 @@ export const OrganizeCategoryForm = () => { product_categories: update.arr, } + // Optimistically update to the new value queryClient.setQueryData(categoriesQueryKeys.list(QUERY), nextValue) return { @@ -72,21 +81,29 @@ export const OrganizeCategoryForm = () => { toast.error(error.message) }, - onSettled: async (_data, _error, variables) => { - await queryClient.invalidateQueries({ - queryKey: categoriesQueryKeys.lists(), - }) + onSettled: async () => { await queryClient.invalidateQueries({ - queryKey: categoriesQueryKeys.detail(variables.value.id), + queryKey: categoriesQueryKeys.all, }) }, }) const handleRankChange = async ( - value: CategoryTreeItem, + value: { + id: UniqueIdentifier + parentId: UniqueIdentifier | null + index: number + }, arr: CategoryTreeItem[] ) => { - await mutateAsync({ value, arr }) + const val = { + id: value.id as string, + parent_category_id: value.parentId as string | null, + rank: value.index, + } + + setSnapshot(arr) + await mutateAsync({ value: val, arr }) } const loading = isPending || isMutating @@ -104,7 +121,8 @@ export const OrganizeCategoryForm = () => { item.name} + value={loading ? snapshot : product_categories || []} onChange={handleRankChange} /> diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx index 06682437a73bd..d911f77873f2e 100644 --- a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx +++ b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/category-tree.tsx @@ -1,90 +1,31 @@ -import { - DotsSix, - FolderIllustration, - FolderOpenIllustration, - Swatch, - TriangleRightMini, -} from "@medusajs/icons" -import { Badge, IconButton, Text, clx } from "@medusajs/ui" -import dropRight from "lodash/dropRight" -import flatMap from "lodash/flatMap" -import get from "lodash/get" +import { UniqueIdentifier } from "@dnd-kit/core" import { ReactNode } from "react" -import Nestable from "react-nestable" -import { useTranslation } from "react-i18next" -import "react-nestable/dist/styles/index.css" +import { SortableTree } from "../../../../../components/common/sortable-tree" import { CategoryTreeItem } from "../../types" -import "./styles.css" type CategoryTreeProps = { value: CategoryTreeItem[] - onChange: (value: CategoryTreeItem, items: CategoryTreeItem[]) => void + onChange: ( + value: { + id: UniqueIdentifier + parentId: UniqueIdentifier | null + index: number + }, + items: CategoryTreeItem[] + ) => void + renderValue: (item: CategoryTreeItem) => ReactNode enableDrag?: boolean | ((item: CategoryTreeItem) => boolean) - showBadge?: (item: CategoryTreeItem) => boolean isLoading?: boolean } export const CategoryTree = ({ value, onChange, + renderValue, enableDrag = true, - showBadge, isLoading = false, }: CategoryTreeProps) => { - const handleDrag = ({ - dragItem, - items, - targetPath, - }: { - dragItem: CategoryTreeItem - items: CategoryTreeItem[] - targetPath: number[] - }) => { - let parentId = null - const [rank] = targetPath.slice(-1) - - if (targetPath.length > 1) { - const path = dropRight( - flatMap(targetPath.slice(0, -1), (item) => [item, "category_children"]) - ) - - const newParent = get(items, path) as CategoryTreeItem - parentId = newParent.id - } - - onChange( - { - ...dragItem, - parent_category_id: parentId, - rank, - }, - items - ) - - return { - ...dragItem, - parent_category_id: parentId, - rank, - } - } - - const getIsEnabled = (item: CategoryTreeItem) => { - if (typeof enableDrag === "function") { - return enableDrag(item) - } - - return enableDrag - } - - const getShowBadge = (item: CategoryTreeItem) => { - if (typeof showBadge === "function") { - return showBadge(item) - } - - return false - } - if (isLoading) { return (
    @@ -96,124 +37,19 @@ export const CategoryTree = ({ } return ( -
    - - handleDrag({ - dragItem: dragItem as CategoryTreeItem, - items: items as CategoryTreeItem[], - targetPath, - }) - } - disableDrag={({ item }) => getIsEnabled(item as CategoryTreeItem)} - renderItem={({ index, item, ...props }) => { - return ( - - ) - }} - renderCollapseIcon={({ isCollapsed }) => { - return - }} - threshold={10} - /> -
    + ) } -const CollapseHandler = ({ isCollapsed }: { isCollapsed: boolean }) => { - return ( -
    - - - -
    - {isCollapsed ? : } -
    -
    - ) -} - -type CategoryBranchProps = { - item: CategoryTreeItem - depth: number - isEnabled: boolean - isNew?: boolean - collapseIcon: ReactNode - handler?: ReactNode -} - -export const CategoryBranch = ({ - item, - depth, - isEnabled, - isNew = false, - collapseIcon, -}: CategoryBranchProps) => { - const { t } = useTranslation() - - const isLeaf = !collapseIcon - - const Component = ( -
    console.log("dragging")} - data-disabled={!isEnabled} - className={clx( - "bg-ui-bg-base hover:bg-ui-bg-base-hover transition-fg group group flex h-12 cursor-grab items-center gap-x-3 border-b px-6 py-2.5 active:cursor-grabbing", - { - "bg-ui-bg-subtle hover:bg-ui-bg-subtle cursor-not-allowed": - !isEnabled, - } - )} - > -
    - -
    - {Array.from({ length: depth }).map((_, i) => ( -
    - ))} -
    {collapseIcon}
    - {isLeaf && ( -
    -
    -
    - -
    -
    - )} - -
    - - {item.name} - - {isNew && ( - - {t("categories.fields.new.label")} - - )} -
    -
    - ) - - return Component -} - const CategoryLeafPlaceholder = () => { return ( -
    +
    ) } diff --git a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css b/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css deleted file mode 100644 index 1f4effa57cb8c..0000000000000 --- a/packages/admin-next/dashboard/src/routes/categories/common/components/category-tree/styles.css +++ /dev/null @@ -1,50 +0,0 @@ -.nestable-item { - margin: 0 !important; -} - -.nestable-item.is-dragging:before { - content: "" !important; - position: absolute !important; - top: 0 !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - background: var(--bg-base-hover) !important; - border-radius: 0 !important; - - transition: 0.3s all !important; - border: 0px !important; - border-bottom: 1px solid var(--border-base) !important; -} - -.nestable-item.is-dragging * { - height: 48px !important; - min-height: 48px !important; - max-height: 48px !important; - overflow: hidden !important; - background: var(--bg-base) !important; - box-shadow: var(--elevation-card-hover) !important; - z-index: 1000 !important; -} - -.nestable-drag-layer > .nestable-list { - box-shadow: var(--elevation-flyout) !important; - border-radius: 6px !important; - overflow: hidden !important; - opacity: 0.8 !important; - width: fit-content !important; -} - -.nestable-item, -.nestable-item-copy { - margin: 0 !important; -} - -.nestable-drag-layer > .nestable-list > .nestable-item-copy > div { - border-bottom: 0px !important; -} - -.nestable-list { - padding: 0 !important; - margin: 0 !important; -} diff --git a/yarn.lock b/yarn.lock index 1e8212080c224..90916961f2cb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4508,7 +4508,6 @@ __metadata: react-hook-form: 7.49.1 react-i18next: 13.5.0 react-jwt: ^1.2.0 - react-nestable: ^3.0.2 react-router-dom: 6.20.1 tailwindcss: ^3.4.1 tsup: ^8.0.2 @@ -25965,24 +25964,6 @@ __metadata: languageName: node linkType: hard -"react-addons-shallow-compare@npm:^15.6.3": - version: 15.6.3 - resolution: "react-addons-shallow-compare@npm:15.6.3" - dependencies: - object-assign: ^4.1.0 - checksum: ad1a2ef7adf1a307b55de58a99ccf40998a1f7c84dcaadb1a2dd40c0e21e911b84fe04ab5f3494a1118846fc7537043287d54bcaffc2daf819b6c45eef5635ce - languageName: node - linkType: hard - -"react-addons-update@npm:^15.6.3": - version: 15.6.3 - resolution: "react-addons-update@npm:15.6.3" - dependencies: - object-assign: ^4.1.0 - checksum: b6d98b459eb37393b8103309090b36649c0a0e95c7dbe010e692616eb025baf62b53cb187925ecce0c3ed0377357ce03c3a45a8ec2d91e6886f8d2eb55b0dfd1 - languageName: node - linkType: hard - "react-aria@npm:^3.33.1": version: 3.33.1 resolution: "react-aria@npm:3.33.1" @@ -26194,19 +26175,6 @@ __metadata: languageName: node linkType: hard -"react-nestable@npm:^3.0.2": - version: 3.0.2 - resolution: "react-nestable@npm:3.0.2" - dependencies: - classnames: ^2.3.2 - react: ^18.2.0 - react-addons-shallow-compare: ^15.6.3 - react-addons-update: ^15.6.3 - react-dom: ^18.2.0 - checksum: e2a7947382ca28e12048c534a2ad6e544dff60a56e712b7a1b00838434c5b5444b4c50c1fd652e032710b567674b4eff36ee4c3a5021c02f933d11c7b88ce7b9 - languageName: node - linkType: hard - "react-refresh@npm:^0.14.0": version: 0.14.2 resolution: "react-refresh@npm:0.14.2"