diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx new file mode 100644 index 000000000..fab9f9fb5 --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -0,0 +1,471 @@ +import { ReactNode, Ref, useEffect, useImperativeHandle, useMemo, useState } from 'react' + +import { IconChevronDown, IconChevronRight, IconChevronSelectorVertical } from '../../icons/index.js' +import { + Box, + Button, + Checkbox, + Divider, + Group, + Input, + Text, + Flex, + BoxProps, + TextProps, + Combobox, + ComboboxProps, + useCombobox, + ComboboxStore, + ActionIcon, + LoadingOverlay, + InputProps, + ElementProps +} from '../../primitive/index.js' + +import type { + LoadData, + OnStatusChange, + OnStatusChangeEvent, + RenderSelectItem, + SelectionProtectType, + SelectOption, + StatusChangeType, + TreeSelectOption +} from './types' +import { checkAll, checkOptionsByValue, flatArrayToTree, isSelectOptionChecked, treeToLeafArray } from './utils.js' + +export interface TreeSelectProps { + options: TreeSelectOption[] | SelectOption[] + value?: T[] + onChange?: (value: T[], target: TreeSelectOption | null) => void + onStatusChange?: OnStatusChange + // works when multiple is true + changeTrigger?: 'onSelect' | 'onConfirm' + loadData?: LoadData + + comboboxProps?: Omit + comboboxRef?: Ref + + target?: ReactNode + defaultTargetProps?: InputProps & ElementProps<'input'> + + selectItemProps?: SelectItemWrapperProps + renderSelectItem?: RenderSelectItem + + // multi-selection or single-selection + multiple?: boolean + emptyMessage?: string + // should the empty array be check all status + allWithEmpty?: boolean + // whether to show check all option + allowSelectAll?: boolean + loading?: boolean + + searchable?: boolean + searchInputProps?: InputProps & ElementProps<'input'> +} + +export const TreeSelect = ({ + options, + value, + onChange, + onStatusChange, + changeTrigger = 'onConfirm', + loadData, + comboboxProps, + comboboxRef, + target, + defaultTargetProps, + selectItemProps, + renderSelectItem, + allWithEmpty = true, + emptyMessage = 'No data.', + allowSelectAll = true, + multiple, + loading, + searchable, + searchInputProps +}: TreeSelectProps) => { + const { disabled } = defaultTargetProps || {} + const combobox = useCombobox() + const [_options, setOptions] = useState[] | undefined>(undefined) + const internalOptions = useMemo[]>(() => { + if (!!_options) { + return _options + } + + if (!!options.length && isSelectOptionChecked(options[0])) { + return flatArrayToTree(options) + } + return options + }, [options, _options]) + const flatOptions = useMemo(() => treeToLeafArray(internalOptions), [internalOptions]) + const itemCount = flatOptions.length + const isCheckAll = value && (allWithEmpty ? !value.length || value.length === itemCount : value.length === itemCount) + const selectedTips = + value === undefined ? '' : isCheckAll ? `All (${itemCount})` : `Checked ${value.length} (Total ${itemCount})` + const everyInternalChecked = flatOptions.every((o) => o.isChecked) + const someInternalChecked = flatOptions.some((o) => o.isChecked) + const everInternalNotChecked = flatOptions.every((o) => !o.isChecked) + const isArray = internalOptions.every((n) => !n.children?.length) + + const _onValueChange = (v: TreeSelectOption[], target: TreeSelectOption | null) => { + const flatOptions = treeToLeafArray(v) + const isCheckAll = flatOptions.every((n) => n.isChecked) + onChange?.(isCheckAll && allWithEmpty ? [] : flatOptions.filter((n) => n.isChecked).map((n) => n.value), target) + } + const _onStatusChange: OnStatusChange = (evt) => { + setOptions(evt.options) + onStatusChange?.(evt) + } + const afterCheckStatusChange: OnStatusChange = (evt) => { + if (evt.type !== 'check') { + return + } + if (multiple && changeTrigger === 'onSelect') { + _onValueChange(evt.options!, evt.target!) + } + if (!multiple) { + const flatOptions = treeToLeafArray([evt.target!]) + onChange?.( + flatOptions.map((n) => n.value), + evt.target! + ) + combobox.closeDropdown() + } + } + const resetCheckedStatus = () => setOptions(checkOptionsByValue(internalOptions, value || [], allWithEmpty)) + + useEffect(() => { + // value could be undefined + _onStatusChange({ type: 'check', options: checkOptionsByValue(options, value || [], allWithEmpty) }) + }, [options, value]) + + useImperativeHandle(comboboxRef, () => combobox, [combobox]) + + return ( + + + {target ? ( + target + ) : ( + } + onClick={() => combobox.toggleDropdown()} + {...defaultTargetProps} + /> + )} + + + {searchable && ( + + + + + )} + + + {internalOptions.length ? ( + <> + {allowSelectAll && ( + <> + { + const allOptions = checkAll(internalOptions, v) + const evt: OnStatusChangeEvent = { + type: 'check', + options: allOptions, + target: { label: 'All', value: 'all' as T, children: allOptions } + } + _onStatusChange(evt) + afterCheckStatusChange(evt) + }} + multiple={multiple} + {...selectItemProps} + /> + + + )} + + + value={internalOptions} + renderSelectItem={renderSelectItem} + onChange={(evt) => { + _onStatusChange(evt) + afterCheckStatusChange(evt) + }} + loadData={loadData} + multiple={multiple} + isArray={isArray} + {...selectItemProps} + /> + + {multiple && changeTrigger === 'onConfirm' && ( + <> + + + + + + + )} + + ) : ( + + {emptyMessage} + + )} + + + + ) +} + +interface SelectItemsProps { + value: TreeSelectOption[] + onChange: OnStatusChange + level?: number + renderSelectItem?: RenderSelectItem + loadData?: LoadData + multiple?: boolean + isArray?: boolean +} + +const SelectItems = ({ + value, + level = 0, + onChange, + ...rest +}: SelectItemsProps) => { + return ( + <> + {value.map((v, i) => { + return ( + + key={v.value} + value={v} + level={level} + onChange={(evt) => + onChange({ ...evt, node: evt.node, options: [...value.slice(0, i), evt.node!, ...value.slice(i + 1)] }) + } + onChildrenChange={(evt) => { + const childChange = { ...v, children: evt.options } + onChange({ + ...evt, + node: childChange, + options: [...value.slice(0, i), childChange, ...value.slice(i + 1)] + }) + }} + {...rest} + /> + ) + })} + + ) +} + +interface SelectItemWithChildrenProps { + value: TreeSelectOption + onChange: OnStatusChange + onChildrenChange: (evt: { + type: StatusChangeType + options: TreeSelectOption[] + target: TreeSelectOption + }) => void + level?: number + renderSelectItem?: (options: SelectItemProps) => React.ReactNode + selectItemProps?: SelectItemWrapperProps + loadData?: (id: T) => Promise + isArray?: boolean +} + +const SelectItemWithChildren = ({ + value, + level = 0, + onChange, + onChildrenChange, + renderSelectItem, + ...rest +}: SelectItemWithChildrenProps) => { + const someChildrenChecked = treeToLeafArray(value.children || []).some((c) => c.isChecked) + const everyChildrenChecked = treeToLeafArray(value.children || []).every((c) => c.isChecked) + return ( + <> + + {...value} + level={level} + someChildrenChecked={someChildrenChecked} + everyChildrenChecked={everyChildrenChecked} + showChildren={value.showChildren} + onShowChildrenChange={(v) => { + const target = { ...value, showChildren: v } + onChange({ type: 'showChildren', node: target, target }) + }} + onCheckStatusChange={(v) => { + const target = { + ...value, + isChecked: v, + children: value.isLeaf ? value.children : checkAll(value.children!, v) + } + onChange({ + type: 'check', + node: target, + target + }) + }} + renderSelectItem={renderSelectItem} + {...rest} + /> + {value.children && value.showChildren && ( + + value={value.children} + level={level + 1} + onChange={onChildrenChange} + renderSelectItem={renderSelectItem} + {...rest} + /> + )} + + ) +} + +interface SelectItemWrapperProps extends Omit { + textProps?: TextProps +} + +interface SelectItemProps extends TreeSelectOption, SelectItemWrapperProps { + onCheckStatusChange: (v: boolean) => void + onShowChildrenChange?: (v: boolean) => void + indeterminate?: boolean + level?: number + showChildren?: boolean + someChildrenChecked?: boolean + everyChildrenChecked?: boolean + multiple?: boolean + loadData?: (id: T) => Promise + isArray?: boolean +} + +const SelectItem = ({ + label, + indeterminate, + level = 0, + isLeaf, + someChildrenChecked, + everyChildrenChecked, + showChildren, + onShowChildrenChange, + onCheckStatusChange, + isChecked, + disabled, + renderSelectItem, + value, + loadData, + multiple, + isArray, + ...rest +}: SelectItemProps) => { + const { textProps, ...wrapperProps } = rest + const [isLoading, setLoading] = useState(false) + + return ( + + + {!isArray && + (!isLeaf ? ( + { + if (!!loadData && !rest.children?.length) { + setLoading(true) + await loadData(value) + setLoading(false) + } + + onShowChildrenChange?.(!showChildren) + }} + > + {showChildren ? : } + + ) : ( + + ))} + { + e.stopPropagation() + const checkStatus = isLeaf ? !isChecked : !everyChildrenChecked + onCheckStatusChange(checkStatus) + }} + > + + {multiple && ( + + )} + {!!renderSelectItem ? ( + renderSelectItem({ label, value, disabled }) + ) : ( + + {label} + + )} + + + + + ) +} diff --git a/packages/uikit/src/biz/TreeSelect/types.ts b/packages/uikit/src/biz/TreeSelect/types.ts new file mode 100644 index 000000000..ed3196dcc --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/types.ts @@ -0,0 +1,40 @@ +export type SelectionProtectType = string | number + +export interface TreeSelectOption { + label: string + value: T + disabled?: boolean + isChecked?: boolean + isLeaf?: boolean + showChildren?: boolean + children?: TreeSelectOption[] + renderSelectItem?: (options: TreeSelectOption) => React.ReactNode +} + +export interface SelectOption extends Omit, 'children'> { + parent?: T +} + +export type StatusChangeType = 'check' | 'showChildren' + +export interface OnStatusChangeEvent { + type: StatusChangeType + target?: TreeSelectOption + node?: TreeSelectOption + options?: TreeSelectOption[] +} +export interface OnStatusChange { + (evt: OnStatusChangeEvent): void +} + +export interface OnValueChange { + (value: TreeSelectOption[]): void +} + +export interface LoadData { + (id: T): Promise +} + +export interface RenderSelectItem { + (options: TreeSelectOption): React.ReactNode +} diff --git a/packages/uikit/src/biz/TreeSelect/utils.ts b/packages/uikit/src/biz/TreeSelect/utils.ts new file mode 100644 index 000000000..d94d827fb --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/utils.ts @@ -0,0 +1,137 @@ +import type { SelectionProtectType, SelectOption, TreeSelectOption } from './types' + +/** + * Check or uncheck all the options in the given TreeSelectOption array. + * + * The given boolean value will be applied to all the options, unless the + * option is disabled. If an option has children, the given boolean value + * will be applied to all of them recursively. + * + * @param options - The TreeSelectOption array to be checked or unchecked + * @param isChecked - The boolean value to be applied to all the options + * @returns The checked or unchecked TreeSelectOption array + */ +export const checkAll = ( + options: TreeSelectOption[], + isChecked: boolean +): TreeSelectOption[] => { + return options.map((v) => ({ + ...v, + // skip disabled + isChecked: v.disabled ? false : isChecked, + children: v.children ? checkAll(v.children, isChecked) : undefined + })) +} + +/** + * Check TreeSelectOption items based on given value array. + * + * If the value array is empty, all options will be checked. + * + * @param options - TreeSelectOption items + * @param value - Value array + * @returns Checked TreeSelectOption items + */ +export const checkOptionsByValue = ( + options: TreeSelectOption[], + value: T[], + allWithEmpty: boolean +): TreeSelectOption[] => { + if (!!options?.length && !value.length) { + return checkAll(options, allWithEmpty) + } + return options.map((op) => { + if (op.children?.length) { + op.children = checkOptionsByValue(op.children, value, allWithEmpty) + } + return { ...op, isChecked: value.includes(op.value) } + }) +} + +/** + * Flatten all leaf nodes from a TreeSelectOption array. + * + * A leaf node is a node that has no children and is not disabled. + * + * @param value - TreeSelectOption array + * @returns An array of leaf nodes + */ +export const treeToLeafArray = ( + value: TreeSelectOption[], + parent?: TreeSelectOption +) => + value.reduce((prev, cur) => { + const { children, ...rest } = cur + if (cur.isLeaf && !cur.disabled) { + prev.push({ ...rest, parent: parent?.value }) + } + if (children) { + prev.push(...treeToLeafArray(children, rest)) + } + return prev + }, [] as SelectOption[]) + +/** + * Flatten a TreeSelectOption array into a plain array of SelectOption. + * + * This function will traverse the tree recursively and return all the leaf + * nodes in a single array. Each leaf node will have a parent field pointing to + * its parent node's value. + * + * @param value - The TreeSelectOption array to be flattened + * @param parent - The parent node of the current node + * @returns An array of SelectOption + */ +export const treeToFlatArray = ( + value: TreeSelectOption[], + parent?: TreeSelectOption +) => { + return value.reduce((prev, cur) => { + const { children, ...rest } = cur + prev.push({ ...rest, parent: parent?.value }) + if (children) { + prev.push(...treeToFlatArray(children, rest)) + } + return prev + }, [] as SelectOption[]) +} + +/** + * Converts a flat array of SelectOption items into a tree structure of TreeSelectOption. + * + * This function organizes the flat array by mapping each option to its parent based on the parent field. + * Options without a parent are considered root nodes and are directly added to the tree array. + * Children are recursively added to their respective parent nodes. + * + * @param value - The flat array of SelectOption items to be converted into a tree structure. + * @returns A TreeSelectOption array representing the hierarchical tree structure. + */ + +export const flatArrayToTree = (value: SelectOption[]) => { + const treeArr: TreeSelectOption[] = [] + const treeMap = new Map>() + value.forEach((v) => { + const { parent, ...rest } = v + treeMap.set(v.value, { ...rest, children: [] }) + }) + value.forEach((v) => { + const node = treeMap.get(v.value)! + if (!v.parent) { + treeArr.push(node) + return + } + + const parent = treeMap.get(v.parent) + if (!parent) { + return + } + + parent.children!.push(node) + }) + + return treeArr +} + +export const isSelectOptionChecked = ( + option: SelectOption | TreeSelectOption +): option is SelectOption => !!(option as SelectOption).parent && !(option as TreeSelectOption).children diff --git a/packages/uikit/src/biz/index.ts b/packages/uikit/src/biz/index.ts index 8cbd2a425..88de1e6d3 100644 --- a/packages/uikit/src/biz/index.ts +++ b/packages/uikit/src/biz/index.ts @@ -12,3 +12,6 @@ export * from './PropertyCard/index.js' export * from './PageShell/index.js' export * from './TimeRangePicker/index.js' export * from './DateTimePicker/index.js' +export * from './TreeSelect/index.js' + +export type * from './TreeSelect/types' diff --git a/packages/uikit/src/primitive/index.ts b/packages/uikit/src/primitive/index.ts index e4192f346..6d6c24708 100644 --- a/packages/uikit/src/primitive/index.ts +++ b/packages/uikit/src/primitive/index.ts @@ -57,6 +57,7 @@ export type { ComboboxProps, ComboboxItem, ComboboxData, + ComboboxStore, PillProps, PillsInputProps, OptionsFilter, diff --git a/stories/uikit/biz/TreeSelect.stories.tsx b/stories/uikit/biz/TreeSelect.stories.tsx new file mode 100644 index 000000000..ece643294 --- /dev/null +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -0,0 +1,466 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Button, ComboboxStore, Stack, Text } from '@tidbcloud/uikit' +import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' +import { useRef, useState } from 'react' + +type Story = StoryObj + +const meta: Meta = { + title: 'Biz/TreeSelect', + component: TreeSelect, + tags: ['autodocs'], + parameters: {} +} + +export default meta + +function getTreeData(): TreeSelectOption[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + isLeaf: false, + children: [ + // { + // label: 'Row-based Storage', + // value: 'TiDB Serverless - Row-based Storage', + // isLeaf: true + // }, + // { + // label: 'Columnar Storage', + // value: 'TiDB Serverless - Columnar Storage', + // isLeaf: true + // }, + // { + // label: 'Request Units', + // value: 'TiDB Serverless - Request Units', + // isLeaf: true + // } + ] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + isLeaf: false, + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + isLeaf: false, + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB', + isLeaf: true + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + isLeaf: false, + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Backup', + value: 'Backup', + isLeaf: false, + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage', + isLeaf: true + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage', + isLeaf: true + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication', + isLeaf: true + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', + isLeaf: true + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', + isLeaf: true + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + isLeaf: false, + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet', + isLeaf: true + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region', + isLeaf: true + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region', + isLeaf: true + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing', + isLeaf: true + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', + isLeaf: true + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link', + isLeaf: true + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + isLeaf: false, + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', + isLeaf: true + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', + isLeaf: true + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', + isLeaf: true + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan', + isLeaf: true + } + ] +} + +function MultipleDemo() { + const [value, setValue] = useState([]) + return ( + { + console.log(`checked:`, v) + setValue(v) + }} + multiple + loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> + ) +} + +function SingleDemo() { + const [value, setValue] = useState([]) + const treeSelectRef = useRef(null) + + return ( + + Selected: {value.join(', ')} + { + console.log(`checked:`, v, target) + setValue(v) + }} + loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + allowSelectAll={false} + target={} + /> + + ) +} + +const code = ` +import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' + +function getTreeData(): TreeSelectOption[] { + return [ + { + label: 'TiDB Serverless', + value: 'TiDB Serverless', + isLeaf: false, + children: [ + { + label: 'Row-based Storage', + value: 'TiDB Serverless - Row-based Storage', + isLeaf: true + }, + { + label: 'Columnar Storage', + value: 'TiDB Serverless - Columnar Storage', + isLeaf: true + }, + { + label: 'Request Units', + value: 'TiDB Serverless - Request Units', + isLeaf: true + } + ] + }, + { + label: 'TiDB Dedicated', + value: 'TiDB Dedicated', + isLeaf: false, + children: [ + { + label: 'Node Compute', + value: 'Node Compute', + isLeaf: false, + children: [ + { + label: 'TiDB', + value: 'TiDB Dedicated - Node Compute - TiDB', + isLeaf: true + }, + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Compute - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Compute - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Node Storage', + value: 'Node Storage', + isLeaf: false, + children: [ + { + label: 'TiKV', + value: 'TiDB Dedicated - Node Storage - TiKV', + isLeaf: true + }, + { + label: 'TiFlash', + value: 'TiDB Dedicated - Node Storage - TiFlash', + isLeaf: true + } + ] + }, + { + label: 'Backup', + value: 'Backup', + isLeaf: false, + children: [ + { + label: 'Single Region Storage', + value: 'TiDB Dedicated - Backup - Single Region Storage', + isLeaf: true + }, + { + label: 'Dual Region Storage', + value: 'TiDB Dedicated - Backup - Dual Region Storage', + isLeaf: true + }, + { + label: 'Replication', + value: 'TiDB Dedicated - Backup - Replication', + isLeaf: true + } + ] + }, + { + label: 'Data Migration', + value: 'Data Migration', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units (RCU)', + value: 'TiDB Dedicated - Data Migration - Replication Capacity Units (RCU)', + isLeaf: true + } + ] + }, + { + label: 'Changefeed', + value: 'Changefeed', + isLeaf: false, + children: [ + { + label: 'Replication Capacity Units', + value: 'TiDB Dedicated - Changefeed - Replication Capacity Units', + isLeaf: true + } + ] + }, + { + label: 'Data Transfer', + value: 'Data Transfer', + isLeaf: false, + children: [ + { + label: 'Internet', + value: 'TiDB Dedicated - Data Transfer - Internet', + isLeaf: true + }, + { + label: 'Cross Region', + value: 'TiDB Dedicated - Data Transfer - Cross Region', + isLeaf: true + }, + { + label: 'Same Region', + value: 'TiDB Dedicated - Data Transfer - Same Region', + isLeaf: true + }, + { + label: 'Load Balancing', + value: 'TiDB Dedicated - Data Transfer - Load Balancing', + isLeaf: true + }, + { + label: 'DM NAT Gateway', + value: 'TiDB Dedicated - Data Transfer - DM NAT Gateway', + isLeaf: true + }, + { + label: 'Private Data Link', + value: 'TiDB Dedicated - Data Transfer - Private Data Link', + isLeaf: true + } + ] + }, + { + label: 'Recovery Group', + value: 'Recovery Group', + isLeaf: false, + children: [ + { + label: 'Recovery Group Service', + value: 'TiDB Dedicated - Recovery Group - Recovery Group Service', + isLeaf: true + }, + { + label: 'Same Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Same Region Data Processing', + isLeaf: true + }, + { + label: 'Cross Region Data Processing', + value: 'TiDB Dedicated - Recovery Group - Cross Region Data Processing', + isLeaf: true + } + ] + } + ] + }, + { + label: 'Support Plan', + value: 'Support Plan', + isLeaf: true + } + ] +} + +function Demo() { + return + new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> +} +` + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +export const Multiple: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +} + +export const Single: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +}