Skip to content

Commit e85ca3a

Browse files
authored
Merge pull request #1867 from merico-dev/tree-select-improvement
feat(filter-tree-select): Add selected items panel with hierarchical paths
2 parents 1a5cf9c + 3950f2f commit e85ca3a

File tree

9 files changed

+319
-20
lines changed

9 files changed

+319
-20
lines changed

api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@devtable/api",
3-
"version": "14.58.11",
3+
"version": "14.59.0",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@devtable/dashboard",
3-
"version": "14.58.11",
3+
"version": "14.59.0",
44
"license": "Apache-2.0",
55
"repository": {
66
"url": "https://github.com/merico-dev/table"
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { ActionIcon, Button, Divider, Group, Stack, Text, Tooltip } from '@mantine/core';
2+
import { IconX } from '@tabler/icons-react';
3+
import { TreeItem } from 'performant-array-to-tree';
4+
import { useTranslation } from 'react-i18next';
5+
6+
interface SelectedItemsPanelProps {
7+
selectedItems: TreeItem[];
8+
itemPaths: Map<string, string>;
9+
onRemoveItem: (value: string) => void;
10+
onClearAll: () => void;
11+
height: number;
12+
}
13+
14+
/**
15+
* Extracts displayable text from a TreeItem label
16+
*/
17+
function getItemLabel(item: TreeItem): string {
18+
if (typeof item.label === 'string') {
19+
return item.label;
20+
}
21+
return item.filterBasis || String(item.value);
22+
}
23+
24+
export const SelectedItemsPanel = ({
25+
selectedItems,
26+
itemPaths,
27+
onRemoveItem,
28+
onClearAll,
29+
height,
30+
}: SelectedItemsPanelProps) => {
31+
const { t } = useTranslation();
32+
33+
const hasItems = selectedItems && selectedItems.length > 0;
34+
35+
return (
36+
<Stack
37+
gap={0}
38+
style={{
39+
height: `${height}px`,
40+
backgroundColor: '#fafafa',
41+
borderLeft: '1px solid #e9ecef',
42+
}}
43+
>
44+
{/* Header */}
45+
{hasItems && (
46+
<>
47+
<Group
48+
justify="space-between"
49+
align="center"
50+
bg="white"
51+
px="xs"
52+
py={1}
53+
style={{
54+
position: 'sticky',
55+
top: 0,
56+
borderBottom: '1px solid #e9ecef',
57+
}}
58+
>
59+
<Text size="xs" fw={500} c="#212529">
60+
{t('filter.widget.tree_select.selected_items')}
61+
</Text>
62+
<Button size="xs" variant="subtle" onClick={onClearAll}>
63+
{t('common.actions.clear')}
64+
</Button>
65+
</Group>
66+
67+
<Divider />
68+
</>
69+
)}
70+
71+
{/* Items List */}
72+
<Stack
73+
gap={4}
74+
p="xs"
75+
style={{
76+
flex: 1,
77+
overflowY: 'auto',
78+
}}
79+
>
80+
{!hasItems ? (
81+
<Text
82+
size="sm"
83+
c="dimmed"
84+
ta="center"
85+
pt="xl"
86+
style={{
87+
userSelect: 'none',
88+
}}
89+
>
90+
{t('filter.widget.tree_select.no_items_selected')}
91+
</Text>
92+
) : (
93+
selectedItems.map((item) => {
94+
const path = itemPaths.get(item.value) || '';
95+
const label = getItemLabel(item);
96+
97+
return (
98+
<Group
99+
key={item.value}
100+
gap="xs"
101+
p={6}
102+
wrap="nowrap"
103+
style={{
104+
borderRadius: '4px',
105+
transition: 'background-color 100ms ease',
106+
cursor: 'default',
107+
backgroundColor: '#fff',
108+
}}
109+
onMouseEnter={(e) => {
110+
e.currentTarget.style.backgroundColor = '#f1f3f5';
111+
}}
112+
onMouseLeave={(e) => {
113+
e.currentTarget.style.backgroundColor = '#fff';
114+
}}
115+
>
116+
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
117+
{/* Item Label */}
118+
<Text
119+
size="sm"
120+
fw={500}
121+
style={{
122+
overflow: 'hidden',
123+
textOverflow: 'ellipsis',
124+
whiteSpace: 'nowrap',
125+
}}
126+
>
127+
{label}
128+
</Text>
129+
{/* Hierarchical Path */}
130+
<Tooltip label={path} disabled={path.length < 30}>
131+
<Text
132+
size="xs"
133+
c="#868e96"
134+
style={{
135+
overflow: 'hidden',
136+
textOverflow: 'ellipsis',
137+
whiteSpace: 'nowrap',
138+
}}
139+
>
140+
{path}
141+
</Text>
142+
</Tooltip>
143+
</Stack>
144+
145+
{/* Delete Button */}
146+
<ActionIcon
147+
size="sm"
148+
variant="subtle"
149+
color="gray"
150+
onClick={() => onRemoveItem(item.value)}
151+
aria-label={`Remove ${label}`}
152+
>
153+
<IconX size={14} />
154+
</ActionIcon>
155+
</Group>
156+
);
157+
})
158+
)}
159+
</Stack>
160+
</Stack>
161+
);
162+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { TreeItem } from 'performant-array-to-tree';
2+
import { useMemo } from 'react';
3+
4+
/**
5+
* Extracts text from a label that can be either string or JSX.Element
6+
* Falls back to filterBasis if label is a React element
7+
*/
8+
function extractLabelText(item: TreeItem): string {
9+
if (typeof item.label === 'string') {
10+
return item.label;
11+
}
12+
// For JSX.Element labels, use filterBasis which is always a string
13+
return item.filterBasis || String(item.value);
14+
}
15+
16+
/**
17+
* Flattens tree structure into a Map for O(1) lookups
18+
*/
19+
function flattenTree(treeData: TreeItem[]): Map<string, TreeItem> {
20+
const flatMap = new Map<string, TreeItem>();
21+
22+
const traverse = (items: TreeItem[]) => {
23+
items.forEach((item) => {
24+
flatMap.set(item.value, item);
25+
if (item.children && item.children.length > 0) {
26+
traverse(item.children);
27+
}
28+
});
29+
};
30+
31+
traverse(treeData);
32+
return flatMap;
33+
}
34+
35+
/**
36+
* Builds hierarchical path for a single tree item
37+
* Returns path like "Parent > Child > Item"
38+
*/
39+
function buildPath(item: TreeItem, flatMap: Map<string, TreeItem>): string {
40+
const path: string[] = [];
41+
let current: TreeItem | undefined = item;
42+
const visited = new Set<string>();
43+
44+
while (current) {
45+
// Prevent infinite loops from circular references
46+
if (visited.has(current.value)) {
47+
break;
48+
}
49+
visited.add(current.value);
50+
51+
// Extract text label
52+
const labelText = extractLabelText(current);
53+
path.unshift(labelText);
54+
55+
// Stop if no parent or parent not found
56+
if (!current.parent_value) {
57+
break;
58+
}
59+
60+
current = flatMap.get(current.parent_value);
61+
}
62+
63+
return path.join(' > ');
64+
}
65+
66+
/**
67+
* Custom hook to build hierarchical paths for selected tree items
68+
*
69+
* @param treeData - Full tree structure
70+
* @param selectedItems - Currently selected items
71+
* @returns Map of value to hierarchical path string
72+
*
73+
* @example
74+
* const paths = useTreePaths(treeData, value);
75+
* const itemPath = paths.get(item.value); // "Parent > Child > Item"
76+
*/
77+
export function useTreePaths(treeData: TreeItem[], selectedItems: TreeItem[]): Map<string, string> {
78+
return useMemo(() => {
79+
if (!treeData || treeData.length === 0) {
80+
return new Map();
81+
}
82+
83+
if (!selectedItems || selectedItems.length === 0) {
84+
return new Map();
85+
}
86+
87+
const flatMap = flattenTree(treeData);
88+
const pathsMap = new Map<string, string>();
89+
90+
selectedItems.forEach((item) => {
91+
const path = buildPath(item, flatMap);
92+
pathsMap.set(item.value, path);
93+
});
94+
95+
return pathsMap;
96+
}, [treeData, selectedItems]);
97+
}

dashboard/src/components/filter/filter-tree/filter-tree-select/render/widget.styles.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ export default createStyles((theme, { radius = 4, width, name }: TreeSelectWidge
128128
zIndex: 300,
129129
// paddingTop: '6px',
130130
position: 'absolute',
131-
width: `${width} !important`,
131+
width: `max(${width}, 500px) !important`,
132+
minWidth: '500px',
132133
backgroundColor: '#fff',
133134
border: '1px solid #e9ecef',
134135
boxShadow: '0 1px 3px rgb(0 0 0 / 5%), rgb(0 0 0 / 5%) 0px 10px 15px -5px, rgb(0 0 0 / 4%) 0px 7px 7px -5px',

dashboard/src/components/filter/filter-tree/filter-tree-select/render/widget.tsx

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Badge, Checkbox, CloseButton, Divider, Group, MantineRadius, Stack, Text, Tooltip } from '@mantine/core';
1+
import { Badge, Checkbox, CloseButton, Divider, Flex, Group, MantineRadius, Stack, Text, Tooltip } from '@mantine/core';
22
import { TreeItem } from 'performant-array-to-tree';
33
import TreeSelect, { SHOW_PARENT } from 'rc-tree-select';
4-
import { useState } from 'react';
4+
import { useCallback, useState } from 'react';
55
import { useTranslation } from 'react-i18next';
66
import { ErrorMessageOrNotFound } from '~/components/filter/error-message-or-not-found';
77
import { SwitcherIcon } from '../../common/switcher-icon';
@@ -10,6 +10,8 @@ import useStyles from './widget.styles';
1010
import { useSelectAll } from './use-select-all';
1111
import { EmotionStyles } from '@mantine/emotion';
1212
import _ from 'lodash';
13+
import { SelectedItemsPanel } from './selected-items-panel';
14+
import { useTreePaths } from './use-tree-paths';
1315

1416
const DropdownHeaderStyles: EmotionStyles = {
1517
root: {
@@ -64,6 +66,23 @@ export const FilterTreeSelectWidget = ({
6466
const selectAll = useSelectAll(treeData, value, onChange, treeCheckStrictly);
6567
const [keyword, setKeyword] = useState('');
6668

69+
// Build hierarchical paths for selected items
70+
const treePaths = useTreePaths(treeData, value);
71+
72+
// Handle individual item removal
73+
const handleRemoveItem = useCallback(
74+
(valueToRemove: string) => {
75+
const newValue = value.filter((item) => item.value !== valueToRemove);
76+
onChange(newValue);
77+
},
78+
[value, onChange],
79+
);
80+
81+
// Handle clear all
82+
const handleClearAll = useCallback(() => {
83+
onChange([]);
84+
}, [onChange]);
85+
6786
return (
6887
<Stack gap={3}>
6988
<Group justify="space-between">
@@ -112,20 +131,36 @@ export const FilterTreeSelectWidget = ({
112131
searchValue={keyword}
113132
onSearch={setKeyword}
114133
dropdownRender={(menu) => (
115-
<>
116-
{selectAll.allValueSet.size > 0 && !keyword && (
117-
<Group px="xs" pt={8} pb={8} onClick={selectAll.toggleSelectAll} styles={DropdownHeaderStyles}>
118-
<Checkbox
119-
size="xs"
120-
checked={selectAll.allSelected}
121-
onChange={_.noop}
122-
label={t('common.actions.select_all')}
123-
/>
124-
</Group>
125-
)}
126-
<Divider />
127-
{menu}
128-
</>
134+
<Flex gap={0} direction="row">
135+
{/* Left Panel: Tree */}
136+
<Stack gap={0} style={{ flex: '1 1 60%', minWidth: 0 }}>
137+
{selectAll.allValueSet.size > 0 && !keyword && (
138+
<Group px="xs" pt={8} pb={8} onClick={selectAll.toggleSelectAll} styles={DropdownHeaderStyles}>
139+
<Checkbox
140+
size="xs"
141+
checked={selectAll.allSelected}
142+
onChange={_.noop}
143+
label={t('common.actions.select_all')}
144+
/>
145+
</Group>
146+
)}
147+
<Divider />
148+
{menu}
149+
</Stack>
150+
151+
<Divider orientation="vertical" />
152+
153+
{/* Right Panel: Selected Items */}
154+
<Stack gap={0} style={{ flex: '0 0 40%', minWidth: 250 }}>
155+
<SelectedItemsPanel
156+
selectedItems={value}
157+
itemPaths={treePaths}
158+
onRemoveItem={handleRemoveItem}
159+
onClearAll={handleClearAll}
160+
height={510}
161+
/>
162+
</Stack>
163+
</Flex>
129164
)}
130165
/>
131166
</Stack>

dashboard/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export const en = {
7676
},
7777
tree_select: {
7878
strictly: 'Parent and children nodes are not associated',
79+
selected_items: 'Selected Items',
80+
no_items_selected: 'No items selected',
7981
},
8082
tree_single_select: {
8183
select_first_option_by_default: 'Select the first option by default',

dashboard/src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export const zh = {
7676
},
7777
tree_select: {
7878
strictly: '枝叶节点不相关,各选各的',
79+
selected_items: '已选项',
80+
no_items_selected: '无已选项',
7981
},
8082
tree_single_select: {
8183
select_first_option_by_default: '默认选中第一个选项',

0 commit comments

Comments
 (0)