Skip to content

Commit

Permalink
feat: add sortable group component
Browse files Browse the repository at this point in the history
  • Loading branch information
moonrailgun committed Sep 15, 2024
1 parent fc1e67e commit ef30750
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 62 deletions.
94 changes: 94 additions & 0 deletions src/client/components/Sortable/SortableContext.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 95 additions & 0 deletions src/client/components/Sortable/SortableGroup.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions src/client/components/Sortable/StrictModeDroppable.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 33 additions & 0 deletions src/client/components/Sortable/types.ts
Original file line number Diff line number Diff line change
@@ -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;
62 changes: 0 additions & 62 deletions src/client/components/SortableGroup.tsx

This file was deleted.

59 changes: 59 additions & 0 deletions src/client/components/monitor/StatusPage/ServiceList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit ef30750

Please sign in to comment.