From ef307508026bf516d321da933c298d87efd0b902 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 15 Sep 2024 21:55:23 +0800 Subject: [PATCH] feat: add sortable group component --- .../components/Sortable/SortableContext.tsx | 94 ++++++++++++++++++ .../components/Sortable/SortableGroup.tsx | 95 +++++++++++++++++++ .../Sortable/StrictModeDroppable.tsx | 27 ++++++ src/client/components/Sortable/types.ts | 33 +++++++ src/client/components/SortableGroup.tsx | 62 ------------ .../monitor/StatusPage/ServiceList.tsx | 59 ++++++++++++ src/client/routeTree.gen.ts | 11 +++ src/client/routes/playground.tsx | 25 +++++ 8 files changed, 344 insertions(+), 62 deletions(-) create mode 100644 src/client/components/Sortable/SortableContext.tsx create mode 100644 src/client/components/Sortable/SortableGroup.tsx create mode 100644 src/client/components/Sortable/StrictModeDroppable.tsx create mode 100644 src/client/components/Sortable/types.ts delete mode 100644 src/client/components/SortableGroup.tsx create mode 100644 src/client/components/monitor/StatusPage/ServiceList.tsx create mode 100644 src/client/routes/playground.tsx diff --git a/src/client/components/Sortable/SortableContext.tsx b/src/client/components/Sortable/SortableContext.tsx new file mode 100644 index 00000000..61029c72 --- /dev/null +++ b/src/client/components/Sortable/SortableContext.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { DragDropContext, DropResult } from 'react-beautiful-dnd'; +import { useEvent } from '@/hooks/useEvent'; +import { reorder } from '@/utils/reorder'; +import { BaseSortableData } from './types'; + +interface SortableContextProps { + list: T[]; + onChange: (list: T[]) => void; + children: React.ReactNode; +} + +export const SortableContext = ( + props: SortableContextProps +) => { + const { list, onChange, children } = props; + + const handleDragEnd = useEvent((result: DropResult) => { + // dropped outside the list + if (!result.destination) { + return; + } + + if (result.type === 'root') { + const final = reorder( + list, + result.source.index, + result.destination.index + ); + onChange(final); + return; + } + + if (result.type === 'group') { + // move data from source to destination + // NOTICE: now only support 1 level + + const final = [...list]; + const sourceGroupIndex = final.findIndex( + (group) => group.id === result.source.droppableId + ); + if (sourceGroupIndex === -1) { + return; + } + const destinationGroupIndex = final.findIndex( + (group) => group.id === result.destination?.droppableId + ); + if (destinationGroupIndex === -1) { + return; + } + + if (sourceGroupIndex === destinationGroupIndex) { + if (!('items' in final[sourceGroupIndex])) { + return; + } + + // same group + final[sourceGroupIndex].items = reorder( + final[sourceGroupIndex].items!, + result.source.index, + result.destination.index + ); + } else { + // cross group + if ( + !('items' in final[sourceGroupIndex]) || + !('items' in final[destinationGroupIndex]) + ) { + return; + } + + const sourceGroupItems = Array.from( + final[sourceGroupIndex].items ?? [] + ); + const [removed] = sourceGroupItems.splice(result.source.index, 1); + + const destinationGroupItems = Array.from( + final[destinationGroupIndex].items ?? [] + ); + destinationGroupItems.splice(result.destination.index, 0, removed); + + final[sourceGroupIndex].items = sourceGroupItems; + final[destinationGroupIndex].items = destinationGroupItems; + } + + onChange(final); + } + }); + + return ( + {children} + ); +}; +SortableContext.displayName = 'SortableGroup'; diff --git a/src/client/components/Sortable/SortableGroup.tsx b/src/client/components/Sortable/SortableGroup.tsx new file mode 100644 index 00000000..ea667622 --- /dev/null +++ b/src/client/components/Sortable/SortableGroup.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { SortableContext } from './SortableContext'; +import { StrictModeDroppable } from './StrictModeDroppable'; +import { useEvent } from '@/hooks/useEvent'; +import { Draggable } from 'react-beautiful-dnd'; +import { BaseSortableData, ExtractGroup, ExtractItem } from './types'; + +interface SortableGroupProps { + list: T[]; + onChange: (list: T[]) => void; + renderGroup: ( + group: ExtractGroup, + children: React.ReactNode + ) => React.ReactNode; + renderItem: (item: ExtractItem) => React.ReactNode; +} + +export const SortableGroup = ( + props: SortableGroupProps +) => { + const { list, onChange, renderGroup, renderItem } = props; + + const renderItemEl = useEvent((item: ExtractItem, index: number) => { + return ( + + {(dragProvided) => ( +
+ {renderItem(item)} +
+ )} +
+ ); + }); + + const renderGroupEl = useEvent((group: ExtractGroup, level = 0) => { + return ( + + {(dropProvided) => ( +
+ {renderGroup( + group, + <> + {group.items.map((item, index) => + item.type === 'item' ? ( + renderItemEl(item as ExtractItem, index) + ) : ( + + {(dragProvided) => ( +
+ {renderGroupEl(item as ExtractGroup, level + 1)} +
+ )} +
+ ) + )} + + )} + + {dropProvided.placeholder} +
+ )} +
+ ); + }); + + return ( + list={list} onChange={onChange}> + {renderGroupEl( + { + id: 'root', + type: 'root' as const, + items: list, + } as any, + 0 + )} + + ); +}; +SortableGroup.displayName = 'SortableGroup'; diff --git a/src/client/components/Sortable/StrictModeDroppable.tsx b/src/client/components/Sortable/StrictModeDroppable.tsx new file mode 100644 index 00000000..1cd78eeb --- /dev/null +++ b/src/client/components/Sortable/StrictModeDroppable.tsx @@ -0,0 +1,27 @@ +import React, { useEffect, useState } from 'react'; +import { Droppable, DroppableProps } from 'react-beautiful-dnd'; + +/** + * https://github.com/atlassian/react-beautiful-dnd/issues/2350 + */ +export const StrictModeDroppable: React.FC = React.memo( + (props) => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const animation = requestAnimationFrame(() => setEnabled(true)); + + return () => { + cancelAnimationFrame(animation); + setEnabled(false); + }; + }, []); + + if (!enabled) { + return null; + } + + return {props.children}; + } +); +StrictModeDroppable.displayName = 'StrictModeDroppable'; diff --git a/src/client/components/Sortable/types.ts b/src/client/components/Sortable/types.ts new file mode 100644 index 00000000..00098be2 --- /dev/null +++ b/src/client/components/Sortable/types.ts @@ -0,0 +1,33 @@ +import { Id } from 'react-beautiful-dnd'; + +export type BaseSortableItem = { + type: 'item'; + id: Id; +}; + +export type BaseSortableGroup = { + type: 'group'; + id: Id; + title?: string; + items: ((BaseSortableGroup & GroupProps) | (BaseSortableItem & ItemProps))[]; +}; + +export type BaseSortableRoot = { + type: 'root'; + id: Id; + title?: string; + items: (BaseSortableItem & GroupProps)[]; +}; + +export type BaseSortableData = + | BaseSortableRoot + | BaseSortableGroup + | BaseSortableItem; + +export type SortableData = + | BaseSortableRoot + | (BaseSortableGroup & GroupProps) + | (BaseSortableItem & ItemProps); + +export type ExtractGroup = T extends { type: 'group' } ? T : never; +export type ExtractItem = T extends { type: 'item' } ? T : never; diff --git a/src/client/components/SortableGroup.tsx b/src/client/components/SortableGroup.tsx deleted file mode 100644 index 45ee6681..00000000 --- a/src/client/components/SortableGroup.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useState } from 'react'; -import { DragDropContext, DropResult } from 'react-beautiful-dnd'; -import { useEvent } from '@/hooks/useEvent'; -import { reorder } from '@/utils/reorder'; - -interface BasicItem { - type: 'root' | 'group' | 'item'; - items?: BasicItem[]; -} - -interface SortableGroupProps { - list: BasicItem[]; - onChange: (list: BasicItem[]) => void; - children: React.ReactNode; -} - -export const SortableGroup: React.FC = React.memo( - (props) => { - const [list, setList] = useState(props.list); - - const handleDragEnd = useEvent((result: DropResult) => { - // dropped outside the list - if (!result.destination) { - return; - } - - if (result.type === 'root') { - setList(reorder(list, result.source.index, result.destination.index)); - return; - } - - if (result.type === 'group') { - const nestedIndex = list.findIndex((item): boolean => 'items' in item); - - if (nestedIndex === 0) { - return; - } - - const nested = list[nestedIndex].items; - if (!nested) { - return; - } - - const children = Array.from(list); - children[nestedIndex].items = reorder( - nested, - result.source.index, - result.destination.index - ); - - setList([...list]); - } - }); - - return ( - - {props.children} - - ); - } -); -SortableGroup.displayName = 'SortableGroup'; diff --git a/src/client/components/monitor/StatusPage/ServiceList.tsx b/src/client/components/monitor/StatusPage/ServiceList.tsx new file mode 100644 index 00000000..e1be43e4 --- /dev/null +++ b/src/client/components/monitor/StatusPage/ServiceList.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import { SortableData } from '@/components/Sortable/types'; +import { SortableGroup } from '@/components/Sortable/SortableGroup'; + +type MonitorStatusPageServiceItem = SortableData<{}, { title: string }>; + +export const MonitorStatusPageServiceList: React.FC = React.memo(() => { + const [list, setList] = useState([ + { + type: 'group', + title: 'Group 1', + id: 'group1', + items: [ + { + id: 'item1', + type: 'item', + title: 'Item 1', + }, + { + id: 'item2', + type: 'item', + title: 'Item 2', + }, + ], + }, + { + type: 'group', + title: 'Group 2', + id: 'group2', + items: [ + { + id: 'item3', + type: 'item', + title: 'Item 3', + }, + { + id: 'item4', + type: 'item', + title: 'Item 4', + }, + ], + }, + ] as MonitorStatusPageServiceItem[]); + + return ( + setList(list)} + renderGroup={(group, children) => ( +
+
{group.title}
+
{children}
+
+ )} + renderItem={(item) =>
{item.id}
} + /> + ); +}); +MonitorStatusPageServiceList.displayName = 'MonitorStatusPageServiceList'; diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index fb2470ce..7cd408ed 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as SurveyImport } from './routes/survey' import { Route as SettingsImport } from './routes/settings' import { Route as ServerImport } from './routes/server' import { Route as RegisterImport } from './routes/register' +import { Route as PlaygroundImport } from './routes/playground' import { Route as PageImport } from './routes/page' import { Route as MonitorImport } from './routes/monitor' import { Route as LoginImport } from './routes/login' @@ -84,6 +85,11 @@ const RegisterRoute = RegisterImport.update({ getParentRoute: () => rootRoute, } as any) +const PlaygroundRoute = PlaygroundImport.update({ + path: '/playground', + getParentRoute: () => rootRoute, +} as any) + const PageRoute = PageImport.update({ path: '/page', getParentRoute: () => rootRoute, @@ -248,6 +254,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PageImport parentRoute: typeof rootRoute } + '/playground': { + preLoaderRoute: typeof PlaygroundImport + parentRoute: typeof rootRoute + } '/register': { preLoaderRoute: typeof RegisterImport parentRoute: typeof rootRoute @@ -387,6 +397,7 @@ export const routeTree = rootRoute.addChildren([ MonitorMonitorIdIndexRoute, ]), PageRoute.addChildren([PageSlugRoute, PageAddRoute]), + PlaygroundRoute, RegisterRoute, ServerRoute, SettingsRoute.addChildren([ diff --git a/src/client/routes/playground.tsx b/src/client/routes/playground.tsx new file mode 100644 index 00000000..cadfd7b2 --- /dev/null +++ b/src/client/routes/playground.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { isDev } from '@/utils/env'; +import { MonitorStatusPageServiceList } from '@/components/monitor/StatusPage/ServiceList'; + +export const Route = createFileRoute('/playground')({ + beforeLoad: () => { + if (!isDev) { + throw redirect({ + to: '/', + }); + } + }, + component: PageComponent, +}); + +function PageComponent(this: { + beforeLoad: () => void; + component: () => import('react/jsx-runtime').JSX.Element; +}) { + return ( +
+ +
+ ); +}