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