feat: refactor sortable group component and add edit body component

This commit is contained in:
moonrailgun 2024-09-16 21:25:31 +08:00
parent 72a1e7b024
commit 946ecaf9f9
8 changed files with 515 additions and 237 deletions

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Badge } from './ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useTranslation } from '@i18next-toolkit/react';
interface DeprecatedBadgeProps {
tip?: string;
}
export const DeprecatedBadge: React.FC<DeprecatedBadgeProps> = React.memo(
(props) => {
const { t } = useTranslation();
const el = (
<Badge className="mx-1 px-1 py-0.5 text-xs" variant="secondary">
{t('Deprecated')}
</Badge>
);
if (!props.tip) {
return el;
} else {
return (
<Tooltip>
<TooltipTrigger>{el}</TooltipTrigger>
<TooltipContent>
<p>{props.tip}</p>
</TooltipContent>
</Tooltip>
);
}
}
);
DeprecatedBadge.displayName = 'DeprecatedBadge';

View File

@ -2,15 +2,15 @@ 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';
import { SortableItem } from './types';
interface SortableContextProps<T extends BaseSortableData = BaseSortableData> {
interface SortableContextProps<T extends SortableItem> {
list: T[];
onChange: (list: T[]) => void;
children: React.ReactNode;
}
export const SortableContext = <T extends BaseSortableData>(
export const SortableContext = <T extends SortableItem>(
props: SortableContextProps<T>
) => {
const { list, onChange, children } = props;
@ -37,50 +37,50 @@ export const SortableContext = <T extends BaseSortableData>(
const final = [...list];
const sourceGroupIndex = final.findIndex(
(group) => group.id === result.source.droppableId
(group) => group.key === result.source.droppableId
);
if (sourceGroupIndex === -1) {
return;
}
const destinationGroupIndex = final.findIndex(
(group) => group.id === result.destination?.droppableId
(group) => group.key === result.destination?.droppableId
);
if (destinationGroupIndex === -1) {
return;
}
if (sourceGroupIndex === destinationGroupIndex) {
if (!('items' in final[sourceGroupIndex])) {
if (!('children' in final[sourceGroupIndex])) {
return;
}
// same group
final[sourceGroupIndex].items = reorder(
final[sourceGroupIndex].items!,
final[sourceGroupIndex].children = reorder(
final[sourceGroupIndex].children!,
result.source.index,
result.destination.index
);
} else {
// cross group
if (
!('items' in final[sourceGroupIndex]) ||
!('items' in final[destinationGroupIndex])
!('children' in final[sourceGroupIndex]) ||
!('children' in final[destinationGroupIndex])
) {
return;
}
const sourceGroupItems = Array.from(
final[sourceGroupIndex].items ?? []
final[sourceGroupIndex].children ?? []
);
const [removed] = sourceGroupItems.splice(result.source.index, 1);
const destinationGroupItems = Array.from(
final[destinationGroupIndex].items ?? []
final[destinationGroupIndex].children ?? []
);
destinationGroupItems.splice(result.destination.index, 0, removed);
final[sourceGroupIndex].items = sourceGroupItems;
final[destinationGroupIndex].items = destinationGroupItems;
final[sourceGroupIndex].children = sourceGroupItems;
final[destinationGroupIndex].children = destinationGroupItems;
}
onChange(final);

View File

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

View File

@ -1,33 +1,14 @@
import { Id } from 'react-beautiful-dnd';
export type BaseSortableItem = {
type: 'item';
id: Id;
};
export type SortableItem<GroupProps = unknown, ItemProps = unknown> =
| SortableLeafItem<ItemProps>
| SortableGroupItem<GroupProps, ItemProps>;
export type BaseSortableGroup<GroupProps = unknown, ItemProps = unknown> = {
type: 'group';
id: Id;
title?: string;
items: ((BaseSortableGroup & GroupProps) | (BaseSortableItem & ItemProps))[];
};
export type SortableGroupItem<GroupProps = unknown, ItemProps = unknown> = {
key: Id;
children: SortableItem<GroupProps, ItemProps>[];
} & GroupProps;
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;
export type SortableLeafItem<ItemProps = unknown> = {
key: Id;
} & ItemProps;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { MonitorPicker, MonitorPickerOld } from '../MonitorPicker';
import { MonitorPicker } from '../MonitorPicker';
import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button';
import { LuMinusCircle, LuPlus } from 'react-icons/lu';
@ -23,17 +23,11 @@ import { domainRegex, slugRegex } from '@tianji/shared';
import { useElementSize } from '@/hooks/useResizeObserver';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ColorTag } from '@/components/ColorTag';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { CollapsibleTrigger } from '@radix-ui/react-collapsible';
import { CaretSortIcon } from '@radix-ui/react-icons';
import { DeprecatedBadge } from '@/components/DeprecatedBadge';
import { groupItemSchema, MonitorStatusPageServiceList } from './ServiceList';
const Text = Typography.Text;
@ -46,6 +40,15 @@ const editFormSchema = z.object({
.regex(domainRegex, 'Invalid domain')
.or(z.literal(''))
.optional(),
body: z
.object({
groups: z.array(groupItemSchema),
})
.default({ groups: [] }),
/**
* @deprecated
*/
monitorList: z.array(
z.object({
id: z.string(),
@ -77,10 +80,20 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
description: '',
domain: '',
monitorList: [],
body: { groups: [] },
},
});
const { fields, append, remove } = useFieldArray({
const showDeprecatedMonitorList = props.initialValues
? Array.isArray(props.initialValues.monitorList) &&
props.initialValues.monitorList.length > 0
: false;
const {
fields: oldMonitorFields,
append,
remove,
} = useFieldArray({
control: form.control,
name: 'monitorList',
keyName: 'key',
@ -191,74 +204,98 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
</CollapsibleContent>
</Collapsible>
{/* MonitorList */}
{/* Body */}
<FormField
control={form.control}
name="monitorList"
render={() => (
name="body.groups"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Monitor List')}</FormLabel>
{fields.map((field, i) => (
<>
{i !== 0 && <Separator />}
<FormLabel>{t('Body')}</FormLabel>
<FormControl>
<MonitorStatusPageServiceList
{...field}
value={field.value ?? []}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div key={field.key} className="mb-2 flex flex-col gap-2">
<Controller
control={form.control}
name={`monitorList.${i}.id`}
render={({ field }) => (
<MonitorPicker
{...field}
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
{/* MonitorList */}
{showDeprecatedMonitorList && (
<FormField
control={form.control}
name="monitorList"
render={() => (
<FormItem className="opacity-50">
<FormLabel>
{t('Monitor List')}
<DeprecatedBadge tip={t('Please use Body field')} />
</FormLabel>
{oldMonitorFields.map((field, i) => (
<>
{i !== 0 && <Separator />}
<div className="flex flex-1 items-center">
<div key={field.key} className="mb-2 flex flex-col gap-2">
<Controller
control={form.control}
name={`monitorList.${i}.showCurrent`}
name={`monitorList.${i}.id`}
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
<MonitorPicker
{...field}
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
<span className="ml-1 flex-1 align-middle text-sm">
{t('Show Latest Value')}
</span>
<div className="flex flex-1 items-center">
<Controller
control={form.control}
name={`monitorList.${i}.showCurrent`}
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<LuMinusCircle
className="cursor-pointer text-lg"
onClick={() => remove(i)}
/>
<span className="ml-1 flex-1 align-middle text-sm">
{t('Show Latest Value')}
</span>
<LuMinusCircle
className="cursor-pointer text-lg"
onClick={() => remove(i)}
/>
</div>
</div>
</div>
</>
))}
</>
))}
<FormMessage />
<FormMessage />
<Button
variant="dashed"
type="button"
onClick={() =>
append({
id: '',
showCurrent: false,
})
}
style={{ width: '60%' }}
Icon={LuPlus}
>
{t('Add Monitor')}
</Button>
</FormItem>
)}
/>
<Button
variant="dashed"
type="button"
onClick={() =>
append({
id: '',
showCurrent: false,
})
}
style={{ width: '60%' }}
Icon={LuPlus}
>
{t('Add Monitor')}
</Button>
</FormItem>
)}
/>
)}
<div className="!mt-8 flex justify-end gap-2">
<Button type="submit" loading={isLoading}>

View File

@ -1,59 +1,243 @@
import React, { useState } from 'react';
import { SortableData } from '@/components/Sortable/types';
import React from 'react';
import { SortableItem } from '@/components/Sortable/types';
import { SortableGroup } from '@/components/Sortable/SortableGroup';
import { z } from 'zod';
import { useEvent } from '@/hooks/useEvent';
import { v1 as uuid } from 'uuid';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@i18next-toolkit/react';
import { LuMinusCircle, LuPlusCircle, LuTrash } from 'react-icons/lu';
import { cn } from '@/utils/style';
import { Separator } from '@/components/ui/separator';
import { MonitorPicker } from '../MonitorPicker';
import { Switch } from '@/components/ui/switch';
import { set } from 'lodash-es';
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>}
/>
);
export const leafItemSchema = z.object({
key: z.string(),
id: z.string(),
type: z.enum(['monitor']),
showCurrent: z.boolean().default(false).optional(),
});
export const groupItemSchema = z.object({
key: z.string(),
title: z.string(),
children: z.array(leafItemSchema),
});
type GroupItemProps = Omit<z.infer<typeof groupItemSchema>, 'key' | 'children'>;
type LeafItemProps = Omit<z.infer<typeof leafItemSchema>, 'key'>;
export type MonitorStatusPageServiceItem = SortableItem<
GroupItemProps,
LeafItemProps
>;
interface MonitorStatusPageServiceListProps {
value: MonitorStatusPageServiceItem[];
onChange: (list: MonitorStatusPageServiceItem[]) => void;
}
export const MonitorStatusPageServiceList: React.FC<MonitorStatusPageServiceListProps> =
React.memo((props) => {
const { t } = useTranslation();
const handleAddGroup = useEvent(() => {
props.onChange([
...props.value,
{
key: uuid(),
title: 'Default',
children: [],
},
]);
});
const handleAddItem = useEvent((groupKey: string) => {
const index = props.value.findIndex((item) => item.key === groupKey);
if (index === -1) {
return;
}
const newList = [...props.value];
if (!('children' in newList[index])) {
return;
}
newList[index].children = [
...newList[index].children,
{
key: uuid(),
id: '',
type: 'monitor',
showCurrent: false,
},
] as MonitorStatusPageServiceItem[];
props.onChange(newList);
});
const handleDeleteGroup = useEvent((groupKey: string) => {
const index = props.value.findIndex((item) => item.key === groupKey);
if (index === -1) {
return;
}
const newList = [...props.value];
newList.splice(index, 1);
props.onChange(newList);
});
const handleDeleteItem = useEvent((groupKey: string, itemKey: string) => {
const newList = [...props.value];
const groupIndex = newList.findIndex((item) => item.key === groupKey);
if (groupIndex === -1) {
return;
}
const group = newList[groupIndex];
if (!('children' in group)) {
return;
}
const itemIndex = group.children.findIndex(
(item) => item.key === itemKey
);
if (itemIndex === -1) {
return;
}
group.children.splice(itemIndex, 1);
props.onChange(newList);
});
const handleUpdateItem = useEvent(
(groupKey: string, itemKey: string, fieldName: string, value: any) => {
const newList = [...props.value];
const groupIndex = newList.findIndex((item) => item.key === groupKey);
if (groupIndex === -1) {
return;
}
const group = newList[groupIndex];
if (!('children' in group)) {
return;
}
const itemIndex = group.children.findIndex(
(item) => item.key === itemKey
);
if (itemIndex === -1) {
return;
}
set(group, `children[${itemIndex}].${fieldName}`, value);
props.onChange(newList);
}
);
return (
<div className="rounded-lg border p-2">
<SortableGroup<GroupItemProps, LeafItemProps>
list={props.value}
onChange={props.onChange}
renderGroup={(group, children, level) => (
<div>
<div className={cn('flex items-center gap-2')}>
<span>{group.title}</span>
{level > 0 && (
<>
<Button
className="h-6 w-6"
variant="outline"
size="icon"
type="button"
Icon={LuPlusCircle}
onClick={() => handleAddItem(group.key)}
/>
<Button
className="h-6 w-6"
variant="outline"
size="icon"
type="button"
Icon={LuTrash}
onClick={() => handleDeleteGroup(group.key)}
/>
</>
)}
</div>
<div
className={cn(level > 0 && 'border-l-4 border-gray-600 p-2')}
>
{children}
</div>
</div>
)}
renderItem={(item, i, group) => {
if (item.type === 'monitor') {
return (
<div key={item.key}>
{i !== 0 && <Separator />}
<div className="mb-2 flex flex-col gap-2">
<MonitorPicker
value={item.id}
onValueChange={(val) =>
handleUpdateItem(group.key, item.key, 'id', val)
}
/>
<div className="flex flex-1 items-center">
<Switch
checked={item.showCurrent ?? false}
onCheckedChange={(val) =>
handleUpdateItem(
group.key,
item.key,
'showCurrent',
val
)
}
/>
<span className="ml-1 flex-1 align-middle text-sm">
{t('Show Latest Value')}
</span>
<LuMinusCircle
className="cursor-pointer text-lg"
onClick={() => handleDeleteItem(group.key, item.key)}
/>
</div>
</div>
</div>
);
}
return null;
}}
/>
{Array.isArray(props.value) && props.value.length === 0 && (
<p className="text-xs opacity-50">
{t('No any group has been created, click button to create one')}
</p>
)}
<Button
className="mt-2"
type="button"
variant="outline"
size="sm"
onClick={handleAddGroup}
>
{t('Add Group')}
</Button>
</div>
);
});
MonitorStatusPageServiceList.displayName = 'MonitorStatusPageServiceList';

View File

@ -87,7 +87,7 @@ FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & {optional?: boolean}
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & { optional?: boolean }
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
const { t } = useTranslation()

View File

@ -1,6 +1,10 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { isDev } from '@/utils/env';
import { MonitorStatusPageServiceList } from '@/components/monitor/StatusPage/ServiceList';
import {
MonitorStatusPageServiceItem,
MonitorStatusPageServiceList,
} from '@/components/monitor/StatusPage/ServiceList';
import { useState } from 'react';
export const Route = createFileRoute('/playground')({
beforeLoad: () => {
@ -13,13 +17,35 @@ export const Route = createFileRoute('/playground')({
component: PageComponent,
});
function PageComponent(this: {
beforeLoad: () => void;
component: () => import('react/jsx-runtime').JSX.Element;
}) {
function PageComponent() {
const [list, setList] = useState<MonitorStatusPageServiceItem[]>([
{
title: 'Group 1',
key: 'group1',
children: [
{
key: 'item1',
id: 'fooo',
type: 'monitor',
},
],
},
{
title: 'Group 2',
key: 'group2',
children: [
{
key: 'item2',
id: 'barr',
type: 'monitor',
},
],
},
]);
return (
<div>
<MonitorStatusPageServiceList />
<MonitorStatusPageServiceList value={list} onChange={setList} />
</div>
);
}