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

@@ -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)
: [],
}
})
}