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
This commit is contained in:
Kasper Fabricius Kristensen
2024-08-18 22:21:03 +02:00
committed by GitHub
parent 66c39ef876
commit 8c784a8b30
15 changed files with 1267 additions and 334 deletions

View File

@@ -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"
},

View File

@@ -0,0 +1 @@
export * from "./sortable-tree"

View File

@@ -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
}

View File

@@ -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 (
<TreeItem
ref={setDraggableNodeRef}
wrapperRef={setDroppableNodeRef}
style={style}
depth={depth}
ghost={isDragging}
disableSelection={iOS}
disableInteraction={isSorting}
disabled={disabled}
handleProps={{
listeners,
attributes,
}}
{...props}
/>
)
}

View File

@@ -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<T extends TreeItem> {
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<T extends TreeItem>({
collapsible = true,
childrenProp = "children", // "children" is the default children prop name
enableDrag = true,
items = [],
indentationWidth = 40,
onChange,
renderValue,
}: Props<T>) {
const [collapsedState, setCollapsedState] = useState<
Record<UniqueIdentifier, boolean>
>({})
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
const [overId, setOverId] = useState<UniqueIdentifier | null>(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<UniqueIdentifier[]>(
(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<T>(
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 (
<DndContext
accessibility={{ announcements }}
sensors={sensors}
collisionDetection={closestCenter}
measuring={measuring}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{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 (
<SortableTreeItem
key={id}
id={id}
value={renderValue(item as unknown as T)}
disabled={disabled}
depth={id === activeId && projected ? projected.depth : depth}
indentationWidth={indentationWidth}
collapsed={Boolean(collapsedState[id] && children.length)}
childCount={children.length}
onCollapse={
collapsible && children.length
? () => handleCollapse(id)
: undefined
}
/>
)
})}
{createPortal(
<DragOverlay dropAnimation={dropAnimationConfig}>
{activeId && activeItem ? (
<SortableTreeItem
id={activeId}
depth={activeItem.depth}
clone
childCount={getChildCount(items, activeId, childrenProp) + 1}
value={renderValue(activeItem as unknown as T)}
indentationWidth={0}
/>
) : null}
</DragOverlay>,
document.body
)}
</SortableContext>
</DndContext>
)
}

View File

@@ -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<HTMLAttributes<HTMLLIElement>, "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<HTMLDivElement, TreeItemProps>(
(
{
childCount,
clone,
depth,
disableSelection,
disableInteraction,
ghost,
handleProps,
indentationWidth,
collapsed,
onCollapse,
style,
value,
disabled,
wrapperRef,
...props
},
ref
) => {
return (
<li
ref={wrapperRef}
style={
{
paddingLeft: `${indentationWidth * depth}px`,
} as React.CSSProperties
}
className={clx("-mb-px list-none", {
"pointer-events-none": disableInteraction,
"select-none": disableSelection,
"[&:first-of-type>div]:border-t-0": !clone,
})}
{...props}
>
<div
ref={ref}
style={style}
className={clx(
"bg-ui-bg-base transition-fg relative flex items-center gap-x-3 border-y px-6 py-2.5",
{
"border-l": depth > 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,
}
)}
>
<Handle {...handleProps} disabled={disabled} />
<Collapse
collapsed={collapsed}
onCollapse={onCollapse}
clone={clone}
/>
<Icon
childrenCount={childCount}
collapsed={collapsed}
clone={clone}
/>
<Value value={value} />
<ChildrenCount clone={clone} childrenCount={childCount} />
</div>
</li>
)
}
)
TreeItem.displayName = "TreeItem"
const Handle = ({
listeners,
attributes,
disabled,
}: HandleProps & { disabled?: boolean }) => {
return (
<IconButton
size="small"
variant="transparent"
type="button"
className={clx("cursor-grab", { "cursor-not-allowed": disabled })}
disabled={disabled}
{...attributes}
{...listeners}
>
<DotsSix />
</IconButton>
)
}
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 (
<div className="flex size-7 items-center justify-center">
{isBranch ? (
isOpen ? (
<FolderOpenIllustration />
) : (
<FolderIllustration />
)
) : (
<Swatch className="text-ui-fg-muted" />
)}
</div>
)
}
type CollapseProps = {
collapsed?: boolean
onCollapse?: () => void
clone?: boolean
}
const Collapse = ({ collapsed, onCollapse, clone }: CollapseProps) => {
if (clone) {
return null
}
if (!onCollapse) {
return <div className="size-7" role="presentation" />
}
return (
<IconButton
size="small"
variant="transparent"
onClick={onCollapse}
type="button"
>
<TriangleRightMini
className={clx("text-ui-fg-subtle transition-transform", {
"rotate-90": !collapsed,
})}
/>
</IconButton>
)
}
type ValueProps = {
value: ReactNode
}
const Value = ({ value }: ValueProps) => {
return (
<div className="txt-compact-small text-ui-fg-subtle flex-grow truncate">
{value}
</div>
)
}
type ChildrenCountProps = {
clone?: boolean
childrenCount?: number
}
const ChildrenCount = ({ clone, childrenCount }: ChildrenCountProps) => {
if (!clone || !childrenCount) {
return null
}
if (clone && childrenCount <= 1) {
return null
}
return (
<Badge size="2xsmall" color="blue" className="absolute -right-2 -top-2">
{childrenCount}
</Badge>
)
}

View File

@@ -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<string, unknown> {
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
}

View File

@@ -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<T extends TreeItem>(
items: T[],
parentId: UniqueIdentifier | null = null,
depth = 0,
childrenProp: string
): FlattenedItem[] {
return items.reduce<FlattenedItem[]>((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<T extends TreeItem>(
items: T[],
childrenProp: string
): FlattenedItem[] {
return flatten(items, undefined, undefined, childrenProp)
}
type ItemUpdate = {
id: UniqueIdentifier
parentId: UniqueIdentifier | null
index: number
}
export function buildTree<T extends TreeItem>(
flattenedItems: FlattenedItem[],
newIndex: number,
childrenProp: string
): { items: T[]; update: ItemUpdate } {
const root = { id: "root", [childrenProp]: [] } as T
const nodes: Record<string, T> = { [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<T extends TreeItem>(
items: T[],
itemId: UniqueIdentifier
) {
return items.find(({ id }) => id === itemId)
}
export function findItemDeep<T extends TreeItem>(
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<TItem extends TreeItem, T extends keyof TItem>(
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<T extends TreeItem>(
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<T extends TreeItem>(
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<T extends TreeItem>(
items: T[],
childrenProp: string
): T[] {
return items.map((item) => {
return {
...item,
[childrenProp]: item[childrenProp]
? listItemsWithChildren(item[childrenProp] as TreeItem[], childrenProp)
: [],
}
})
}

View File

@@ -732,7 +732,6 @@ export const DataGridRoot = <
}
if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
console.log("Undo/Redo")
handleUndo(e)
return
}

View File

@@ -121,11 +121,15 @@ export const CreateCategoryForm = ({
return (
<RouteFocusModal.Form form={form}>
<ProgressTabs
value={activeTab}
onValueChange={(tab) => handleTabChange(tab as Tab)}
<form
onSubmit={handleSubmit}
className="flex size-full flex-col overflow-hidden"
>
<form onSubmit={handleSubmit}>
<ProgressTabs
value={activeTab}
onValueChange={(tab) => handleTabChange(tab as Tab)}
className="flex size-full flex-col"
>
<RouteFocusModal.Header>
<div className="flex w-full items-center justify-between">
<div className="-my-2 w-fit border-l">
@@ -133,57 +137,68 @@ export const CreateCategoryForm = ({
<ProgressTabs.Trigger
value={Tab.DETAILS}
status={detailsStatus}
className="w-full min-w-0 max-w-[200px] overflow-hidden"
>
{t("categories.create.tabs.details")}
<span className="truncate">
{t("categories.create.tabs.details")}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.ORGANIZE}
status={nestingStatus}
className="w-full min-w-0 max-w-[200px] overflow-hidden"
>
{t("categories.create.tabs.organize")}
<span className="truncate">
{t("categories.create.tabs.organize")}
</span>
</ProgressTabs.Trigger>
</ProgressTabs.List>
</div>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
{activeTab === Tab.ORGANIZE ? (
<Button
key="submit-btn"
size="small"
variant="primary"
type="submit"
isLoading={isPending}
>
{t("actions.save")}
</Button>
) : (
<Button
key="continue-btn"
size="small"
variant="primary"
type="button"
onClick={() => handleTabChange(Tab.ORGANIZE)}
>
{t("actions.continue")}
</Button>
)}
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body>
<RouteFocusModal.Body className="flex size-full flex-col overflow-auto">
<ProgressTabs.Content value={Tab.DETAILS}>
<CreateCategoryDetails form={form} />
</ProgressTabs.Content>
<ProgressTabs.Content value={Tab.ORGANIZE}>
<ProgressTabs.Content
value={Tab.ORGANIZE}
className="bg-ui-bg-subtle flex-1"
>
<CreateCategoryNesting form={form} shouldFreeze={shouldFreeze} />
</ProgressTabs.Content>
</RouteFocusModal.Body>
</form>
</ProgressTabs>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
{activeTab === Tab.ORGANIZE ? (
<Button
key="submit-btn"
size="small"
variant="primary"
type="submit"
isLoading={isPending}
>
{t("actions.save")}
</Button>
) : (
<Button
key="continue-btn"
size="small"
variant="primary"
type="button"
onClick={() => handleTabChange(Tab.ORGANIZE)}
>
{t("actions.continue")}
</Button>
)}
</div>
</RouteFocusModal.Footer>
</ProgressTabs>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -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<CategoryTreeItem[]>([])
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 (
<CategoryTree
// When we submit the form we want to freeze the rendered tree to prevent flickering during the exit animation
value={shouldFreeze ? snapshot : value}
enableDrag={(item) => 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 (
<div className="flex items-center gap-x-3">
<span>{item.name}</span>
<Badge size="2xsmall" color="blue">
{t("categories.fields.new.label")}
</Badge>
</div>
)
}
return item.name
}}
isLoading={!ready}
/>
)
}

View File

@@ -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<CategoryTreeItem[]>([])
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) => {
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: categoriesQueryKeys.lists(),
})
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 = () => {
</RouteFocusModal.Header>
<RouteFocusModal.Body className="bg-ui-bg-subtle flex flex-1 flex-col overflow-y-auto">
<CategoryTree
value={product_categories || []}
renderValue={(item) => item.name}
value={loading ? snapshot : product_categories || []}
onChange={handleRankChange}
/>
</RouteFocusModal.Body>

View File

@@ -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 (
<div className="txt-compact-small relative flex-1 overflow-y-auto">
@@ -96,124 +37,19 @@ export const CategoryTree = ({
}
return (
<div className="txt-compact-small relative flex-1 overflow-y-auto">
<Nestable
items={value}
childrenProp="category_children"
onChange={({ dragItem, items, targetPath }) =>
handleDrag({
dragItem: dragItem as CategoryTreeItem,
items: items as CategoryTreeItem[],
targetPath,
})
}
disableDrag={({ item }) => getIsEnabled(item as CategoryTreeItem)}
renderItem={({ index, item, ...props }) => {
return (
<CategoryBranch
key={index}
item={item as CategoryTreeItem}
isEnabled={getIsEnabled(item as CategoryTreeItem)}
isNew={getShowBadge(item as CategoryTreeItem)}
{...props}
/>
)
}}
renderCollapseIcon={({ isCollapsed }) => {
return <CollapseHandler isCollapsed={isCollapsed} />
}}
threshold={10}
/>
</div>
<SortableTree
items={value}
childrenProp="category_children"
collapsible
enableDrag={enableDrag}
onChange={onChange}
renderValue={renderValue}
/>
)
}
const CollapseHandler = ({ isCollapsed }: { isCollapsed: boolean }) => {
return (
<div className="flex items-center gap-x-3">
<IconButton size="small" variant="transparent" type="button">
<TriangleRightMini
className={clx({
"rotate-90 transform transition-transform": !isCollapsed,
})}
/>
</IconButton>
<div
className="text-ui-fg-muted flex h-7 w-7 items-center justify-center"
role="presentation"
>
{isCollapsed ? <FolderIllustration /> : <FolderOpenIllustration />}
</div>
</div>
)
}
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 = (
<div
onDrag={() => 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,
}
)}
>
<div className="flex h-7 w-7 items-center justify-center">
<DotsSix className="text-ui-fg-subtle" />
</div>
{Array.from({ length: depth }).map((_, i) => (
<div key={`offset_${i}`} role="presentation" className="h-7 w-7" />
))}
<div>{collapseIcon}</div>
{isLeaf && (
<div role="presentation" className="flex items-center">
<div className="size-7" />
<div className="text-ui-fg-muted flex h-7 w-7 items-center justify-center">
<Swatch />
</div>
</div>
)}
<div className="flex items-center gap-x-3">
<Text size="small" leading="compact">
{item.name}
</Text>
{isNew && (
<Badge size="2xsmall" color="blue">
{t("categories.fields.new.label")}
</Badge>
)}
</div>
</div>
)
return Component
}
const CategoryLeafPlaceholder = () => {
return (
<div className="bg-ui-bg-base flex h-12 animate-pulse items-center border-b px-6 py-2.5" />
<div className="bg-ui-bg-base -mb-px flex h-12 animate-pulse items-center border-y px-6 py-2.5" />
)
}

View File

@@ -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;
}

View File

@@ -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"