feat: add sortable group component
This commit is contained in:
parent
fc1e67e005
commit
ef30750802
94
src/client/components/Sortable/SortableContext.tsx
Normal file
94
src/client/components/Sortable/SortableContext.tsx
Normal 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';
|
95
src/client/components/Sortable/SortableGroup.tsx
Normal file
95
src/client/components/Sortable/SortableGroup.tsx
Normal 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';
|
27
src/client/components/Sortable/StrictModeDroppable.tsx
Normal file
27
src/client/components/Sortable/StrictModeDroppable.tsx
Normal 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';
|
33
src/client/components/Sortable/types.ts
Normal file
33
src/client/components/Sortable/types.ts
Normal 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;
|
@ -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';
|
|
59
src/client/components/monitor/StatusPage/ServiceList.tsx
Normal file
59
src/client/components/monitor/StatusPage/ServiceList.tsx
Normal 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';
|
@ -18,6 +18,7 @@ import { Route as SurveyImport } from './routes/survey'
|
|||||||
import { Route as SettingsImport } from './routes/settings'
|
import { Route as SettingsImport } from './routes/settings'
|
||||||
import { Route as ServerImport } from './routes/server'
|
import { Route as ServerImport } from './routes/server'
|
||||||
import { Route as RegisterImport } from './routes/register'
|
import { Route as RegisterImport } from './routes/register'
|
||||||
|
import { Route as PlaygroundImport } from './routes/playground'
|
||||||
import { Route as PageImport } from './routes/page'
|
import { Route as PageImport } from './routes/page'
|
||||||
import { Route as MonitorImport } from './routes/monitor'
|
import { Route as MonitorImport } from './routes/monitor'
|
||||||
import { Route as LoginImport } from './routes/login'
|
import { Route as LoginImport } from './routes/login'
|
||||||
@ -84,6 +85,11 @@ const RegisterRoute = RegisterImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const PlaygroundRoute = PlaygroundImport.update({
|
||||||
|
path: '/playground',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const PageRoute = PageImport.update({
|
const PageRoute = PageImport.update({
|
||||||
path: '/page',
|
path: '/page',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@ -248,6 +254,10 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof PageImport
|
preLoaderRoute: typeof PageImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/playground': {
|
||||||
|
preLoaderRoute: typeof PlaygroundImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
'/register': {
|
'/register': {
|
||||||
preLoaderRoute: typeof RegisterImport
|
preLoaderRoute: typeof RegisterImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
@ -387,6 +397,7 @@ export const routeTree = rootRoute.addChildren([
|
|||||||
MonitorMonitorIdIndexRoute,
|
MonitorMonitorIdIndexRoute,
|
||||||
]),
|
]),
|
||||||
PageRoute.addChildren([PageSlugRoute, PageAddRoute]),
|
PageRoute.addChildren([PageSlugRoute, PageAddRoute]),
|
||||||
|
PlaygroundRoute,
|
||||||
RegisterRoute,
|
RegisterRoute,
|
||||||
ServerRoute,
|
ServerRoute,
|
||||||
SettingsRoute.addChildren([
|
SettingsRoute.addChildren([
|
||||||
|
25
src/client/routes/playground.tsx
Normal file
25
src/client/routes/playground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user