feat: add sortable group component

This commit is contained in:
moonrailgun 2024-09-15 21:55:23 +08:00
parent fc1e67e005
commit ef30750802
8 changed files with 344 additions and 62 deletions

View File

@ -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<T extends BaseSortableData = BaseSortableData> {
list: T[];
onChange: (list: T[]) => void;
children: React.ReactNode;
}
export const SortableContext = <T extends BaseSortableData>(
props: SortableContextProps<T>
) => {
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 (
<DragDropContext onDragEnd={handleDragEnd}>{children}</DragDropContext>
);
};
SortableContext.displayName = 'SortableGroup';

View File

@ -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<T extends BaseSortableData> {
list: T[];
onChange: (list: T[]) => void;
renderGroup: (
group: ExtractGroup<T>,
children: React.ReactNode
) => React.ReactNode;
renderItem: (item: ExtractItem<T>) => React.ReactNode;
}
export const SortableGroup = <T extends BaseSortableData>(
props: SortableGroupProps<T>
) => {
const { list, onChange, renderGroup, renderItem } = props;
const renderItemEl = useEvent((item: ExtractItem<T>, index: number) => {
return (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(dragProvided) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
{renderItem(item)}
</div>
)}
</Draggable>
);
});
const renderGroupEl = useEvent((group: ExtractGroup<T>, level = 0) => {
return (
<StrictModeDroppable
droppableId={group.id}
type={group.type}
key={group.id}
>
{(dropProvided) => (
<div ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{renderGroup(
group,
<>
{group.items.map((item, index) =>
item.type === 'item' ? (
renderItemEl(item as ExtractItem<T>, index)
) : (
<Draggable
draggableId={item.id}
key={item.id}
index={index}
>
{(dragProvided) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
{renderGroupEl(item as ExtractGroup<T>, level + 1)}
</div>
)}
</Draggable>
)
)}
</>
)}
{dropProvided.placeholder}
</div>
)}
</StrictModeDroppable>
);
});
return (
<SortableContext<T> list={list} onChange={onChange}>
{renderGroupEl(
{
id: 'root',
type: 'root' as const,
items: list,
} as any,
0
)}
</SortableContext>
);
};
SortableGroup.displayName = 'SortableGroup';

View File

@ -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<DroppableProps> = React.memo(
(props) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
return <Droppable {...props}>{props.children}</Droppable>;
}
);
StrictModeDroppable.displayName = 'StrictModeDroppable';

View File

@ -0,0 +1,33 @@
import { Id } from 'react-beautiful-dnd';
export type BaseSortableItem = {
type: 'item';
id: Id;
};
export type BaseSortableGroup<GroupProps = unknown, ItemProps = unknown> = {
type: 'group';
id: Id;
title?: string;
items: ((BaseSortableGroup & GroupProps) | (BaseSortableItem & ItemProps))[];
};
export type BaseSortableRoot<GroupProps = unknown> = {
type: 'root';
id: Id;
title?: string;
items: (BaseSortableItem & GroupProps)[];
};
export type BaseSortableData =
| BaseSortableRoot
| BaseSortableGroup
| BaseSortableItem;
export type SortableData<GroupProps = unknown, ItemProps = unknown> =
| BaseSortableRoot<GroupProps>
| (BaseSortableGroup<GroupProps, ItemProps> & GroupProps)
| (BaseSortableItem & ItemProps);
export type ExtractGroup<T> = T extends { type: 'group' } ? T : never;
export type ExtractItem<T> = T extends { type: 'item' } ? T : never;

View File

@ -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<SortableGroupProps> = 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 (
<DragDropContext onDragEnd={handleDragEnd}>
{props.children}
</DragDropContext>
);
}
);
SortableGroup.displayName = 'SortableGroup';

View File

@ -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<MonitorStatusPageServiceItem[]>([
{
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 (
<SortableGroup
list={list}
onChange={(list) => setList(list)}
renderGroup={(group, children) => (
<div>
<div>{group.title}</div>
<div className="p-2">{children}</div>
</div>
)}
renderItem={(item) => <div>{item.id}</div>}
/>
);
});
MonitorStatusPageServiceList.displayName = 'MonitorStatusPageServiceList';

View File

@ -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([

View File

@ -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 (
<div>
<MonitorStatusPageServiceList />
</div>
);
}