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:
committed by
GitHub
parent
66c39ef876
commit
8c784a8b30
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./sortable-tree"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -732,7 +732,6 @@ export const DataGridRoot = <
|
||||
}
|
||||
|
||||
if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
|
||||
console.log("Undo/Redo")
|
||||
handleUndo(e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
32
yarn.lock
32
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"
|
||||
|
||||
Reference in New Issue
Block a user