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

View File

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

View File

@ -1,33 +1,14 @@
import { Id } from 'react-beautiful-dnd'; import { Id } from 'react-beautiful-dnd';
export type BaseSortableItem = { export type SortableItem<GroupProps = unknown, ItemProps = unknown> =
type: 'item'; | SortableLeafItem<ItemProps>
id: Id; | SortableGroupItem<GroupProps, ItemProps>;
};
export type BaseSortableGroup<GroupProps = unknown, ItemProps = unknown> = { export type SortableGroupItem<GroupProps = unknown, ItemProps = unknown> = {
type: 'group'; key: Id;
id: Id; children: SortableItem<GroupProps, ItemProps>[];
title?: string; } & GroupProps;
items: ((BaseSortableGroup & GroupProps) | (BaseSortableItem & ItemProps))[];
};
export type BaseSortableRoot<GroupProps = unknown> = { export type SortableLeafItem<ItemProps = unknown> = {
type: 'root'; key: Id;
id: Id; } & ItemProps;
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,5 +1,5 @@
import React from 'react'; import React from 'react';
import { MonitorPicker, MonitorPickerOld } from '../MonitorPicker'; import { MonitorPicker } from '../MonitorPicker';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LuMinusCircle, LuPlus } from 'react-icons/lu'; import { LuMinusCircle, LuPlus } from 'react-icons/lu';
@ -23,17 +23,11 @@ import { domainRegex, slugRegex } from '@tianji/shared';
import { useElementSize } from '@/hooks/useResizeObserver'; import { useElementSize } from '@/hooks/useResizeObserver';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator'; 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 { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { CollapsibleTrigger } from '@radix-ui/react-collapsible'; import { CollapsibleTrigger } from '@radix-ui/react-collapsible';
import { CaretSortIcon } from '@radix-ui/react-icons'; import { CaretSortIcon } from '@radix-ui/react-icons';
import { DeprecatedBadge } from '@/components/DeprecatedBadge';
import { groupItemSchema, MonitorStatusPageServiceList } from './ServiceList';
const Text = Typography.Text; const Text = Typography.Text;
@ -46,6 +40,15 @@ const editFormSchema = z.object({
.regex(domainRegex, 'Invalid domain') .regex(domainRegex, 'Invalid domain')
.or(z.literal('')) .or(z.literal(''))
.optional(), .optional(),
body: z
.object({
groups: z.array(groupItemSchema),
})
.default({ groups: [] }),
/**
* @deprecated
*/
monitorList: z.array( monitorList: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
@ -77,10 +80,20 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
description: '', description: '',
domain: '', domain: '',
monitorList: [], 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, control: form.control,
name: 'monitorList', name: 'monitorList',
keyName: 'key', keyName: 'key',
@ -191,14 +204,37 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Body */}
<FormField
control={form.control}
name="body.groups"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Body')}</FormLabel>
<FormControl>
<MonitorStatusPageServiceList
{...field}
value={field.value ?? []}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* MonitorList */} {/* MonitorList */}
{showDeprecatedMonitorList && (
<FormField <FormField
control={form.control} control={form.control}
name="monitorList" name="monitorList"
render={() => ( render={() => (
<FormItem> <FormItem className="opacity-50">
<FormLabel>{t('Monitor List')}</FormLabel> <FormLabel>
{fields.map((field, i) => ( {t('Monitor List')}
<DeprecatedBadge tip={t('Please use Body field')} />
</FormLabel>
{oldMonitorFields.map((field, i) => (
<> <>
{i !== 0 && <Separator />} {i !== 0 && <Separator />}
@ -259,6 +295,7 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
</FormItem> </FormItem>
)} )}
/> />
)}
<div className="!mt-8 flex justify-end gap-2"> <div className="!mt-8 flex justify-end gap-2">
<Button type="submit" loading={isLoading}> <Button type="submit" loading={isLoading}>

View File

@ -1,59 +1,243 @@
import React, { useState } from 'react'; import React from 'react';
import { SortableData } from '@/components/Sortable/types'; import { SortableItem } from '@/components/Sortable/types';
import { SortableGroup } from '@/components/Sortable/SortableGroup'; 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 leafItemSchema = z.object({
key: z.string(),
id: z.string(),
type: z.enum(['monitor']),
showCurrent: z.boolean().default(false).optional(),
});
export const MonitorStatusPageServiceList: React.FC = React.memo(() => { export const groupItemSchema = z.object({
const [list, setList] = useState<MonitorStatusPageServiceItem[]>([ 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,
{ {
type: 'group', key: uuid(),
title: 'Group 1', title: 'Default',
id: 'group1', children: [],
items: [ },
]);
});
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,
{ {
id: 'item1', key: uuid(),
type: 'item', id: '',
title: 'Item 1', type: 'monitor',
showCurrent: false,
}, },
{ ] as MonitorStatusPageServiceItem[];
id: 'item2',
type: 'item', props.onChange(newList);
title: 'Item 2', });
},
], const handleDeleteGroup = useEvent((groupKey: string) => {
}, const index = props.value.findIndex((item) => item.key === groupKey);
{ if (index === -1) {
type: 'group', return;
title: 'Group 2', }
id: 'group2',
items: [ const newList = [...props.value];
{
id: 'item3', newList.splice(index, 1);
type: 'item',
title: 'Item 3', props.onChange(newList);
}, });
{
id: 'item4', const handleDeleteItem = useEvent((groupKey: string, itemKey: string) => {
type: 'item', const newList = [...props.value];
title: 'Item 4', const groupIndex = newList.findIndex((item) => item.key === groupKey);
}, if (groupIndex === -1) {
], return;
}, }
] as MonitorStatusPageServiceItem[]); 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 ( return (
<SortableGroup <div className="rounded-lg border p-2">
list={list} <SortableGroup<GroupItemProps, LeafItemProps>
onChange={(list) => setList(list)} list={props.value}
renderGroup={(group, children) => ( onChange={props.onChange}
renderGroup={(group, children, level) => (
<div> <div>
<div>{group.title}</div> <div className={cn('flex items-center gap-2')}>
<div className="p-2">{children}</div> <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> </div>
)} )}
renderItem={(item) => <div>{item.id}</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'; MonitorStatusPageServiceList.displayName = 'MonitorStatusPageServiceList';

View File

@ -1,6 +1,10 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { isDev } from '@/utils/env'; 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')({ export const Route = createFileRoute('/playground')({
beforeLoad: () => { beforeLoad: () => {
@ -13,13 +17,35 @@ export const Route = createFileRoute('/playground')({
component: PageComponent, component: PageComponent,
}); });
function PageComponent(this: { function PageComponent() {
beforeLoad: () => void; const [list, setList] = useState<MonitorStatusPageServiceItem[]>([
component: () => import('react/jsx-runtime').JSX.Element; {
}) { 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 ( return (
<div> <div>
<MonitorStatusPageServiceList /> <MonitorStatusPageServiceList value={list} onChange={setList} />
</div> </div>
); );
} }