From c44589b525974f464650a6619312e0abcc57e5ed Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 17 Dec 2024 14:56:04 +0800 Subject: [PATCH 1/7] feat(biz): init TreeSelect --- packages/uikit/src/biz/TreeSelect/index.tsx | 444 ++++++++++++++++++++ packages/uikit/src/biz/TreeSelect/types.ts | 36 ++ packages/uikit/src/biz/TreeSelect/utils.ts | 68 +++ packages/uikit/src/biz/index.ts | 3 + packages/uikit/src/primitive/index.ts | 1 + stories/uikit/biz/TreeSelect.stories.tsx | 421 +++++++++++++++++++ 6 files changed, 973 insertions(+) create mode 100644 packages/uikit/src/biz/TreeSelect/index.tsx create mode 100644 packages/uikit/src/biz/TreeSelect/types.ts create mode 100644 packages/uikit/src/biz/TreeSelect/utils.ts create mode 100644 stories/uikit/biz/TreeSelect.stories.tsx diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx new file mode 100644 index 000000000..bbd915da4 --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -0,0 +1,444 @@ +import { + Box, + Button, + Checkbox, + Divider, + Group, + Input, + Text, + Flex, + BoxProps, + TextProps, + Combobox, + ComboboxProps, + useCombobox, + ComboboxStore, + ActionIcon +} from '@mantine/core' +import { ReactNode, Ref, useEffect, useImperativeHandle, useMemo, useState } from 'react' + +import { IconChevronDown, IconChevronRight, IconChevronSelectorVertical } from '../../icons/index.js' + +import type { + LoadData, + OnStatusChange, + OnStatusChangeEvent, + OnValueChange, + RenderSelectItem, + SelectionProtectType, + StatusChangeType, + TreeSelectOption +} from './types' +import { checkAll, checkOptionsByValue, treeToFlatArray } from './utils.js' + +export interface TreeSelectProps { + options: TreeSelectOption[] + value?: T[] + onChange?: (value: T[]) => void + onStatusChange?: OnStatusChange + // works when multiple is true + triggerChangeMode?: 'onStatusChange' | 'onConfirm' + loadData?: LoadData + + comboboxProps?: Omit + comboboxRef?: Ref + + target?: ReactNode + defaultTargetProps?: { + disabled?: boolean + invalid?: boolean + placeholder?: string + } + + selectItemProps?: SelectItemWrapperProps + renderSelectItem?: RenderSelectItem + + multiple?: boolean + emptyMessage?: string + allWithEmpty?: boolean + showCheckAll?: boolean + loading?: boolean +} + +export const TreeSelect = ({ + options, + value, + onChange, + onStatusChange, + triggerChangeMode = 'onConfirm', + loadData, + comboboxProps, + comboboxRef, + target, + defaultTargetProps, + selectItemProps, + renderSelectItem, + allWithEmpty = true, + emptyMessage = 'No data.', + showCheckAll = true, + multiple +}: TreeSelectProps) => { + const { disabled, invalid, placeholder } = defaultTargetProps || {} + const combobox = useCombobox() + const [_options, setOptions] = useState[] | undefined>(undefined) + const internalOptions = useMemo(() => { + if (!!_options) { + return _options + } + return options + }, [options, _options]) + const flatOptions = useMemo(() => treeToFlatArray(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[]) => { + const flatOptions = treeToFlatArray(v) + const isCheckAll = flatOptions.every((n) => n.isChecked) + onChange?.(isCheckAll && allWithEmpty ? [] : flatOptions.filter((n) => n.isChecked).map((n) => n.value)) + } + const _onStatusChange: OnStatusChange = (evt) => { + setOptions(evt.options) + onStatusChange?.(evt) + } + const afterCheckStatusChange: OnStatusChange = (evt) => { + if (evt.type !== 'check') { + return + } + if (multiple && triggerChangeMode === 'onStatusChange') { + _onValueChange(evt.options!) + } + if (!multiple) { + const flatOptions = treeToFlatArray([evt.target!]) + onChange?.(flatOptions.map((n) => n.value)) + 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()} + data-invalid={invalid} + /> + )} + + + {internalOptions.length ? ( + showCheckAll ? ( + <> + { + const evt: OnStatusChangeEvent = { type: 'check', options: checkAll(internalOptions, v) } + _onStatusChange(evt) + afterCheckStatusChange(evt) + }} + multiple={multiple} + {...selectItemProps} + /> + + + ) : ( + <> + ) + ) : ( + + {emptyMessage} + + )} + + + value={internalOptions} + renderSelectItem={renderSelectItem} + onChange={(evt) => { + _onStatusChange(evt) + afterCheckStatusChange(evt) + }} + loadData={loadData} + multiple={multiple} + isArray={isArray} + {...selectItemProps} + /> + + {multiple && triggerChangeMode === 'onConfirm' && ( + <> + + + + + + + )} + + + ) +} + +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 = treeToFlatArray(value.children || []).some((c) => c.isChecked) + const everyChildrenChecked = treeToFlatArray(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..9cd091442 --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/types.ts @@ -0,0 +1,36 @@ +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 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..f1a4edf78 --- /dev/null +++ b/packages/uikit/src/biz/TreeSelect/utils.ts @@ -0,0 +1,68 @@ +import type { SelectionProtectType, 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 treeToFlatArray = (value: TreeSelectOption[]) => + value.reduce((prev, cur) => { + if (cur.isLeaf && !cur.disabled) { + prev.push(cur) + } + if (cur.children) { + prev.push(...treeToFlatArray(cur.children)) + } + return prev + }, [] as TreeSelectOption[]) 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..c54e17df2 --- /dev/null +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -0,0 +1,421 @@ +import { resolve } from 'path' + +import type { Meta, StoryObj } from '@storybook/react' +import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' +import { IconChevronSelectorVertical } from '@tidbcloud/uikit/icons' +import { Input } from 'packages/uikit/dist/primitive' +import { useEffect, useRef } 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 Demo() { + return ( + console.log(`checked:`, args)} + multiple + // triggerChangeMode="onStatusChange" + loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> + ) +} + +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 +} +` + +// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing +export const Primary: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +} From 2289adde7d75bacb88f3a30895328ab4ac1bb31e Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 17 Dec 2024 15:54:40 +0800 Subject: [PATCH 2/7] tweak: stories --- packages/uikit/src/biz/TreeSelect/index.tsx | 29 +++++--- packages/uikit/src/biz/TreeSelect/types.ts | 4 + packages/uikit/src/biz/TreeSelect/utils.ts | 81 +++++++++++++++++++-- stories/uikit/biz/TreeSelect.stories.tsx | 48 +++++++++--- 4 files changed, 136 insertions(+), 26 deletions(-) diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx index bbd915da4..983cb1e12 100644 --- a/packages/uikit/src/biz/TreeSelect/index.tsx +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -23,16 +23,16 @@ import type { LoadData, OnStatusChange, OnStatusChangeEvent, - OnValueChange, RenderSelectItem, SelectionProtectType, + SelectOption, StatusChangeType, TreeSelectOption } from './types' -import { checkAll, checkOptionsByValue, treeToFlatArray } from './utils.js' +import { checkAll, checkOptionsByValue, flatArrayToTree, isSelectOptionChecked, treeToLeafArray } from './utils.js' export interface TreeSelectProps { - options: TreeSelectOption[] + options: TreeSelectOption[] | SelectOption[] value?: T[] onChange?: (value: T[]) => void onStatusChange?: OnStatusChange @@ -81,13 +81,17 @@ export const TreeSelect = ({ const { disabled, invalid, placeholder } = defaultTargetProps || {} const combobox = useCombobox() const [_options, setOptions] = useState[] | undefined>(undefined) - const internalOptions = useMemo(() => { + const internalOptions = useMemo[]>(() => { if (!!_options) { return _options } + + if (isSelectOptionChecked(options[0])) { + return flatArrayToTree(options) + } return options }, [options, _options]) - const flatOptions = useMemo(() => treeToFlatArray(internalOptions), [internalOptions]) + const flatOptions = useMemo(() => treeToLeafArray(internalOptions), [internalOptions]) const itemCount = flatOptions.length const isCheckAll = value && (allWithEmpty ? !value.length || value.length === itemCount : value.length === itemCount) const selectedTips = @@ -98,7 +102,7 @@ export const TreeSelect = ({ const isArray = internalOptions.every((n) => !n.children?.length) const _onValueChange = (v: TreeSelectOption[]) => { - const flatOptions = treeToFlatArray(v) + const flatOptions = treeToLeafArray(v) const isCheckAll = flatOptions.every((n) => n.isChecked) onChange?.(isCheckAll && allWithEmpty ? [] : flatOptions.filter((n) => n.isChecked).map((n) => n.value)) } @@ -114,7 +118,7 @@ export const TreeSelect = ({ _onValueChange(evt.options!) } if (!multiple) { - const flatOptions = treeToFlatArray([evt.target!]) + const flatOptions = treeToLeafArray([evt.target!]) onChange?.(flatOptions.map((n) => n.value)) combobox.closeDropdown() } @@ -178,7 +182,12 @@ export const TreeSelect = ({ isChecked={everyInternalChecked} indeterminate={someInternalChecked && !everyInternalChecked} onCheckStatusChange={(v) => { - const evt: OnStatusChangeEvent = { type: 'check', options: checkAll(internalOptions, v) } + 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) }} @@ -307,8 +316,8 @@ const SelectItemWithChildren = ({ renderSelectItem, ...rest }: SelectItemWithChildrenProps) => { - const someChildrenChecked = treeToFlatArray(value.children || []).some((c) => c.isChecked) - const everyChildrenChecked = treeToFlatArray(value.children || []).every((c) => c.isChecked) + const someChildrenChecked = treeToLeafArray(value.children || []).some((c) => c.isChecked) + const everyChildrenChecked = treeToLeafArray(value.children || []).every((c) => c.isChecked) return ( <> diff --git a/packages/uikit/src/biz/TreeSelect/types.ts b/packages/uikit/src/biz/TreeSelect/types.ts index 9cd091442..ed3196dcc 100644 --- a/packages/uikit/src/biz/TreeSelect/types.ts +++ b/packages/uikit/src/biz/TreeSelect/types.ts @@ -11,6 +11,10 @@ export interface TreeSelectOption { renderSelectItem?: (options: TreeSelectOption) => React.ReactNode } +export interface SelectOption extends Omit, 'children'> { + parent?: T +} + export type StatusChangeType = 'check' | 'showChildren' export interface OnStatusChangeEvent { diff --git a/packages/uikit/src/biz/TreeSelect/utils.ts b/packages/uikit/src/biz/TreeSelect/utils.ts index f1a4edf78..e668392ef 100644 --- a/packages/uikit/src/biz/TreeSelect/utils.ts +++ b/packages/uikit/src/biz/TreeSelect/utils.ts @@ -1,4 +1,4 @@ -import type { SelectionProtectType, TreeSelectOption } from './types' +import type { SelectionProtectType, SelectOption, TreeSelectOption } from './types' /** * Check or uncheck all the options in the given TreeSelectOption array. @@ -56,13 +56,82 @@ export const checkOptionsByValue = ( * @param value - TreeSelectOption array * @returns An array of leaf nodes */ -export const treeToFlatArray = (value: TreeSelectOption[]) => +export const treeToLeafArray = ( + value: TreeSelectOption[], + parent?: TreeSelectOption +) => value.reduce((prev, cur) => { + const { children, ...rest } = cur if (cur.isLeaf && !cur.disabled) { - prev.push(cur) + prev.push({ ...rest, parent: parent?.value }) } - if (cur.children) { - prev.push(...treeToFlatArray(cur.children)) + if (children) { + prev.push(...treeToLeafArray(children, rest)) } return prev - }, [] as TreeSelectOption[]) + }, [] 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 diff --git a/stories/uikit/biz/TreeSelect.stories.tsx b/stories/uikit/biz/TreeSelect.stories.tsx index c54e17df2..9208d9000 100644 --- a/stories/uikit/biz/TreeSelect.stories.tsx +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -1,10 +1,5 @@ -import { resolve } from 'path' - import type { Meta, StoryObj } from '@storybook/react' import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' -import { IconChevronSelectorVertical } from '@tidbcloud/uikit/icons' -import { Input } from 'packages/uikit/dist/primitive' -import { useEffect, useRef } from 'react' type Story = StoryObj @@ -200,7 +195,7 @@ function getTreeData(): TreeSelectOption[] { ] } -function Demo() { +function MultipleDemo() { return ( console.log(`checked:`, args)} multiple - // triggerChangeMode="onStatusChange" loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} /> ) } +function SingleDemo() { + return ( + console.log(`checked:`, args)} + loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + showCheckAll={false} + /> + ) +} + const code = ` import { TreeSelect, TreeSelectOption } from '@tidbcloud/uikit/biz' @@ -401,12 +408,33 @@ function getTreeData(): TreeSelectOption[] { } function Demo() { - return + return + new Promise((resolve) => setTimeout(() => resolve([]), 1000))} + /> } ` // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing -export const Primary: Story = { +export const Multiple: Story = { + parameters: { + controls: { expanded: true }, + docs: { + source: { + language: 'jsx', + code + } + } + }, + render: () => , + args: {} +} + +export const Single: Story = { parameters: { controls: { expanded: true }, docs: { @@ -416,6 +444,6 @@ export const Primary: Story = { } } }, - render: () => , + render: () => , args: {} } From b16e02c7bb854334a27dd0d8b4d9aa7f9f41a2c2 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 17 Dec 2024 18:43:32 +0800 Subject: [PATCH 3/7] tweak: styles --- packages/uikit/src/biz/TreeSelect/index.tsx | 38 ++++++++++++++------- stories/uikit/biz/TreeSelect.stories.tsx | 37 ++++++++++++++------ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx index 983cb1e12..4d426e527 100644 --- a/packages/uikit/src/biz/TreeSelect/index.tsx +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -1,3 +1,6 @@ +import { ReactNode, Ref, useEffect, useImperativeHandle, useMemo, useState } from 'react' + +import { IconChevronDown, IconChevronRight, IconChevronSelectorVertical } from '../../icons/index.js' import { Box, Button, @@ -14,10 +17,7 @@ import { useCombobox, ComboboxStore, ActionIcon -} from '@mantine/core' -import { ReactNode, Ref, useEffect, useImperativeHandle, useMemo, useState } from 'react' - -import { IconChevronDown, IconChevronRight, IconChevronSelectorVertical } from '../../icons/index.js' +} from '../../primitive/index.js' import type { LoadData, @@ -34,7 +34,7 @@ import { checkAll, checkOptionsByValue, flatArrayToTree, isSelectOptionChecked, export interface TreeSelectProps { options: TreeSelectOption[] | SelectOption[] value?: T[] - onChange?: (value: T[]) => void + onChange?: (value: T[], target: TreeSelectOption | null) => void onStatusChange?: OnStatusChange // works when multiple is true triggerChangeMode?: 'onStatusChange' | 'onConfirm' @@ -53,11 +53,17 @@ export interface TreeSelectProps + // 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 showCheckAll?: boolean loading?: boolean + + showSearch?: boolean + onSearchChange?: (value: string) => void } export const TreeSelect = ({ @@ -76,7 +82,10 @@ export const TreeSelect = ({ allWithEmpty = true, emptyMessage = 'No data.', showCheckAll = true, - multiple + multiple, + loading, + showSearch, + onSearchChange }: TreeSelectProps) => { const { disabled, invalid, placeholder } = defaultTargetProps || {} const combobox = useCombobox() @@ -101,10 +110,10 @@ export const TreeSelect = ({ const everInternalNotChecked = flatOptions.every((o) => !o.isChecked) const isArray = internalOptions.every((n) => !n.children?.length) - const _onValueChange = (v: TreeSelectOption[]) => { + 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)) + onChange?.(isCheckAll && allWithEmpty ? [] : flatOptions.filter((n) => n.isChecked).map((n) => n.value), target) } const _onStatusChange: OnStatusChange = (evt) => { setOptions(evt.options) @@ -115,11 +124,14 @@ export const TreeSelect = ({ return } if (multiple && triggerChangeMode === 'onStatusChange') { - _onValueChange(evt.options!) + _onValueChange(evt.options!, evt.target!) } if (!multiple) { const flatOptions = treeToLeafArray([evt.target!]) - onChange?.(flatOptions.map((n) => n.value)) + onChange?.( + flatOptions.map((n) => n.value), + evt.target! + ) combobox.closeDropdown() } } @@ -235,7 +247,7 @@ export const TreeSelect = ({ } + /> + ) } From 18741a51ba40cf0a901cc0ee11fdabc4d2f48a7f Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 17 Dec 2024 19:06:58 +0800 Subject: [PATCH 4/7] feat: search input --- packages/uikit/src/biz/TreeSelect/index.tsx | 160 +++++++++++--------- stories/uikit/biz/TreeSelect.stories.tsx | 31 ++-- 2 files changed, 102 insertions(+), 89 deletions(-) diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx index 4d426e527..aecbae036 100644 --- a/packages/uikit/src/biz/TreeSelect/index.tsx +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -16,7 +16,10 @@ import { ComboboxProps, useCombobox, ComboboxStore, - ActionIcon + ActionIcon, + LoadingOverlay, + InputProps, + ElementProps } from '../../primitive/index.js' import type { @@ -63,7 +66,7 @@ export interface TreeSelectProps void + searchInputProps?: InputProps & ElementProps<'input'> } export const TreeSelect = ({ @@ -85,7 +88,7 @@ export const TreeSelect = ({ multiple, loading, showSearch, - onSearchChange + searchInputProps }: TreeSelectProps) => { const { disabled, invalid, placeholder } = defaultTargetProps || {} const combobox = useCombobox() @@ -95,7 +98,7 @@ export const TreeSelect = ({ return _options } - if (isSelectOptionChecked(options[0])) { + if (!!options.length && isSelectOptionChecked(options[0])) { return flatArrayToTree(options) } return options @@ -183,80 +186,89 @@ export const TreeSelect = ({ )} - {internalOptions.length ? ( - showCheckAll ? ( + {showSearch && ( + + + + + )} + + + {internalOptions.length ? ( <> - { - 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} - /> - + {showCheckAll && ( + <> + { + 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 && triggerChangeMode === 'onConfirm' && ( + <> + + + + + + + )} ) : ( - <> - ) - ) : ( - - {emptyMessage} - - )} - - - value={internalOptions} - renderSelectItem={renderSelectItem} - onChange={(evt) => { - _onStatusChange(evt) - afterCheckStatusChange(evt) - }} - loadData={loadData} - multiple={multiple} - isArray={isArray} - {...selectItemProps} - /> + + {emptyMessage} + + )} - {multiple && triggerChangeMode === 'onConfirm' && ( - <> - - - - - - - )} ) diff --git a/stories/uikit/biz/TreeSelect.stories.tsx b/stories/uikit/biz/TreeSelect.stories.tsx index 81305fbbe..835280486 100644 --- a/stories/uikit/biz/TreeSelect.stories.tsx +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -21,21 +21,21 @@ function getTreeData(): TreeSelectOption[] { 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: '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 + // } ] }, { @@ -201,6 +201,7 @@ function MultipleDemo() { const [value, setValue] = useState([]) return ( console.log(e.target.value) }} comboboxProps={{ width: 'target' }} value={value} options={getTreeData()} From ad6cc6b3d2059c5ea8f1b581fb9208e2f047e507 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Tue, 17 Dec 2024 19:10:34 +0800 Subject: [PATCH 5/7] chore: update story --- stories/uikit/biz/TreeSelect.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/stories/uikit/biz/TreeSelect.stories.tsx b/stories/uikit/biz/TreeSelect.stories.tsx index 835280486..94907d374 100644 --- a/stories/uikit/biz/TreeSelect.stories.tsx +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -201,7 +201,6 @@ function MultipleDemo() { const [value, setValue] = useState([]) return ( console.log(e.target.value) }} comboboxProps={{ width: 'target' }} value={value} options={getTreeData()} From b9b49f0035dfcc16c1aeead0fec04735a1d0e36a Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 18 Dec 2024 10:11:46 +0800 Subject: [PATCH 6/7] refine: apis --- packages/uikit/src/biz/TreeSelect/index.tsx | 31 +++++++++------------ packages/uikit/src/biz/TreeSelect/utils.ts | 2 +- stories/uikit/biz/TreeSelect.stories.tsx | 2 +- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx index aecbae036..1200cb0e9 100644 --- a/packages/uikit/src/biz/TreeSelect/index.tsx +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -40,18 +40,14 @@ export interface TreeSelectProps | null) => void onStatusChange?: OnStatusChange // works when multiple is true - triggerChangeMode?: 'onStatusChange' | 'onConfirm' + changeTrigger?: 'onSelect' | 'onConfirm' loadData?: LoadData comboboxProps?: Omit comboboxRef?: Ref target?: ReactNode - defaultTargetProps?: { - disabled?: boolean - invalid?: boolean - placeholder?: string - } + defaultTargetProps?: InputProps & ElementProps<'input'> selectItemProps?: SelectItemWrapperProps renderSelectItem?: RenderSelectItem @@ -62,10 +58,10 @@ export interface TreeSelectProps } @@ -74,7 +70,7 @@ export const TreeSelect = ({ value, onChange, onStatusChange, - triggerChangeMode = 'onConfirm', + changeTrigger = 'onConfirm', loadData, comboboxProps, comboboxRef, @@ -84,13 +80,13 @@ export const TreeSelect = ({ renderSelectItem, allWithEmpty = true, emptyMessage = 'No data.', - showCheckAll = true, + allowSelectAll = true, multiple, loading, - showSearch, + searchable, searchInputProps }: TreeSelectProps) => { - const { disabled, invalid, placeholder } = defaultTargetProps || {} + const { disabled } = defaultTargetProps || {} const combobox = useCombobox() const [_options, setOptions] = useState[] | undefined>(undefined) const internalOptions = useMemo[]>(() => { @@ -126,7 +122,7 @@ export const TreeSelect = ({ if (evt.type !== 'check') { return } - if (multiple && triggerChangeMode === 'onStatusChange') { + if (multiple && changeTrigger === 'onSelect') { _onValueChange(evt.options!, evt.target!) } if (!multiple) { @@ -177,16 +173,15 @@ export const TreeSelect = ({ }} value={selectedTips} disabled={disabled} - placeholder={placeholder} readOnly rightSection={} onClick={() => combobox.toggleDropdown()} - data-invalid={invalid} + {...defaultTargetProps} /> )} - {showSearch && ( + {searchable && ( @@ -196,7 +191,7 @@ export const TreeSelect = ({ {internalOptions.length ? ( <> - {showCheckAll && ( + {allowSelectAll && ( <> ({ {...selectItemProps} /> - {multiple && triggerChangeMode === 'onConfirm' && ( + {multiple && changeTrigger === 'onConfirm' && ( <> diff --git a/packages/uikit/src/biz/TreeSelect/utils.ts b/packages/uikit/src/biz/TreeSelect/utils.ts index e668392ef..d94d827fb 100644 --- a/packages/uikit/src/biz/TreeSelect/utils.ts +++ b/packages/uikit/src/biz/TreeSelect/utils.ts @@ -134,4 +134,4 @@ export const flatArrayToTree = (value: export const isSelectOptionChecked = ( option: SelectOption | TreeSelectOption -): option is SelectOption => !!(option as SelectOption).parent +): option is SelectOption => !!(option as SelectOption).parent && !(option as TreeSelectOption).children diff --git a/stories/uikit/biz/TreeSelect.stories.tsx b/stories/uikit/biz/TreeSelect.stories.tsx index 94907d374..ece643294 100644 --- a/stories/uikit/biz/TreeSelect.stories.tsx +++ b/stories/uikit/biz/TreeSelect.stories.tsx @@ -231,7 +231,7 @@ function SingleDemo() { setValue(v) }} loadData={() => new Promise((resolve) => setTimeout(() => resolve([]), 1000))} - showCheckAll={false} + allowSelectAll={false} target={} /> From 09ad5c9ad3e40b80a64add8c20857200ce9b0d36 Mon Sep 17 00:00:00 2001 From: Suhaha Date: Wed, 18 Dec 2024 10:13:45 +0800 Subject: [PATCH 7/7] chore: update props --- packages/uikit/src/biz/TreeSelect/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/uikit/src/biz/TreeSelect/index.tsx b/packages/uikit/src/biz/TreeSelect/index.tsx index 1200cb0e9..fab9f9fb5 100644 --- a/packages/uikit/src/biz/TreeSelect/index.tsx +++ b/packages/uikit/src/biz/TreeSelect/index.tsx @@ -172,7 +172,6 @@ export const TreeSelect = ({ lineHeight: '1.55' }} value={selectedTips} - disabled={disabled} readOnly rightSection={} onClick={() => combobox.toggleDropdown()}