From 72440cc705d299152ea38b0957898c4fe87dce01 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Tue, 16 Dec 2025 00:55:02 -0500 Subject: [PATCH 1/6] feat(webui): Add full-width title click for tree node toggle in file lister (resolves #1779). --- .../PathsSelectFormItem/index.module.css | 5 ++ .../Compress/PathsSelectFormItem/index.tsx | 72 +++++++++++++++---- 2 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css new file mode 100644 index 0000000000..fe35b5fbca --- /dev/null +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css @@ -0,0 +1,5 @@ +.treeTitle { + cursor: pointer; + display: inline-block; + width: 100%; +} diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx index bd3c11c679..ca68219881 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx @@ -1,4 +1,4 @@ -import { +import React, { useCallback, useEffect, useRef, @@ -14,6 +14,7 @@ import { } from "antd"; import {listFiles} from "../../../../api/os"; +import styles from "./index.module.css"; import SwitcherIcon from "./SwitcherIcon"; import { ROOT_NODE, @@ -30,6 +31,7 @@ type LoadDataNode = Parameters>[0]; type TreeExpandKeys = Parameters>[0]; + /** * Form item with TreeSelect for selecting file paths for compression. * Supports lazy loading and search with auto-expand. @@ -41,18 +43,6 @@ const PathsSelectFormItem = () => { const [expandedKeys, setExpandedKeys] = useState([]); const [listHeight, setListHeight] = useState(getListHeight); - useEffect(() => { - const handleResize = () => { - setListHeight(getListHeight()); - }; - - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - // Use a ref, instead of a state passed to AntD's `treeLoadedKeys`, to dedupe load requests. const loadedPathsRef = useRef(new Set()); @@ -98,10 +88,65 @@ const PathsSelectFormItem = () => { } }, [loadPath]); + const handleTitleClick = useCallback((nodeData: TreeNode, ev: React.MouseEvent) => { + if (nodeData.isLeaf) { + // Propagate event to let TreeSelect handle selection. + return; + } + ev.stopPropagation(); + + const nodeValue = nodeData.value; + if (expandedKeys.includes(nodeValue)) { + setExpandedKeys(expandedKeys.filter((k) => k !== nodeValue)); + + return; + } + + if (false === loadedPathsRef.current.has(nodeValue)) { + loadPath(nodeValue).catch((e: unknown) => { + console.error("Failed to load directory:", e); + message.error(e instanceof Error ? + e.message : + "Unknown error while loading paths"); + }); + } + + setExpandedKeys([ + ...expandedKeys, + nodeValue, + ]); + }, [ + expandedKeys, + loadPath, + ]); + const handleTreeExpand = useCallback((keys: TreeExpandKeys) => { setExpandedKeys(keys as string[]); }, []); + const renderTreeTitle = useCallback((nodeData: TreeNode) => ( + { + handleTitleClick(nodeData, ev); + }} + > + {nodeData.title} + + ), [handleTitleClick]); + + useEffect(() => { + const handleResize = () => { + setListHeight(getListHeight()); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + return ( { treeExpandedKeys={expandedKeys} treeLine={true} treeNodeLabelProp={"value"} + treeTitleRender={renderTreeTitle} onTreeExpand={handleTreeExpand}/> ); From bbc713bd58bd9f40a660c904e3e97b0f2887e1b5 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Wed, 17 Dec 2025 10:24:32 -0500 Subject: [PATCH 2/6] refactor(webui): Extract error handling logic for path loading into `handleLoadError` --- .../Compress/PathsSelectFormItem/index.tsx | 25 +++---------------- .../Compress/PathsSelectFormItem/utils.ts | 15 +++++++++++ 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx index 85486b0a41..3e5a2646de 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx @@ -8,7 +8,6 @@ import React, { import {FileEntry} from "@webui/common/schemas/os"; import { Form, - message, TreeSelect, TreeSelectProps, } from "antd"; @@ -24,6 +23,7 @@ import { import { addServerPrefix, getListHeight, + handleLoadError, toTreeNode, } from "./utils"; @@ -81,26 +81,14 @@ const PathsSelectFormItem = () => { .then(() => { setExpandedKeys([ROOT_PATH]); }) - .catch((e: unknown) => { - console.error("Failed to load root directory:", e); - message.error(e instanceof Error ? - e.message : - "Failed to load root directory"); - }); + .catch(handleLoadError); }, [loadPath]); const handleLoadData = useCallback(async ({value}: LoadDataNode) => { if ("string" !== typeof value) { return; } - try { - await loadPath(value); - } catch (e) { - console.error("Failed to load directory:", e); - message.error(e instanceof Error ? - e.message : - "Unknown error while loading paths"); - } + await loadPath(value).catch(handleLoadError); }, [loadPath]); const handleTitleClick = useCallback((nodeData: TreeNode, ev: React.MouseEvent) => { @@ -118,12 +106,7 @@ const PathsSelectFormItem = () => { } if (false === loadedPathsRef.current.has(nodeValue)) { - loadPath(nodeValue).catch((e: unknown) => { - console.error("Failed to load directory:", e); - message.error(e instanceof Error ? - e.message : - "Unknown error while loading paths"); - }); + loadPath(nodeValue).catch(handleLoadError); } setExpandedKeys([ diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts index 3c2991ffb5..79bfe56b4b 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts @@ -1,4 +1,5 @@ import {FileEntry} from "@webui/common/schemas/os"; +import {message} from "antd"; import {settings} from "../../../../settings"; import { @@ -98,9 +99,23 @@ const toTreeNode = (fileEntry: FileEntry, parentPath: string): TreeNode => { }; +/** + * Logs and displays an error message for path loading failures. + * + * @param e + */ +const handleLoadError = (e: unknown): void => { + console.error("Failed to load path:", e); + message.error(e instanceof Error ? + e.message : + "Failed to load path"); +}; + + export { addServerPrefix, getListHeight, + handleLoadError, ROOT_PATH, toTreeNode, }; From 35f6ca14c725efe70be4cdd68623ab0c6b052e96 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Mon, 5 Jan 2026 20:46:28 -0500 Subject: [PATCH 3/6] feat(component): Implement DirectoryTreeSelect for enhanced directory navigation --- .../DirectoryTreeSelect.tsx | 152 ++++++++++++++++++ .../PathsSelectFormItem/SwitcherIcon.tsx | 27 ---- .../PathsSelectFormItem/index.module.css | 6 +- .../Compress/PathsSelectFormItem/index.tsx | 117 +++----------- .../Compress/PathsSelectFormItem/utils.ts | 40 +++++ 5 files changed, 217 insertions(+), 125 deletions(-) create mode 100644 components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx delete mode 100644 components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/SwitcherIcon.tsx diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx new file mode 100644 index 0000000000..2ab989fc8a --- /dev/null +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx @@ -0,0 +1,152 @@ +import React, { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import { + Tree, + TreeSelect, +} from "antd"; +import type {TreeProps} from "antd/es"; + +import styles from "./index.module.css"; +import {TreeNode} from "./typings"; +import { + flatToHierarchy, + getListHeight, +} from "./utils"; + + +const {DirectoryTree} = Tree; + + +interface DirectoryTreeSelectProps { + expandedKeys: string[]; + treeData: TreeNode[]; + onChange: (values: string[]) => void; + onDataLoad: (path: string) => Promise; + onExpand: (keys: string[]) => void; +} + +/** + * TreeSelect component that uses DirectoryTree for the popup. + * Provides folder/file icons and intuitive directory navigation. + * + * @param props + * @param props.expandedKeys + * @param props.treeData + * @param props.onChange + * @param props.onDataLoad + * @param props.onExpand + * @return + * @see https://ant.design/components/tree#tree-demo-directory + */ +const DirectoryTreeSelect = ({ + expandedKeys, + treeData, + onChange, + onDataLoad, + onExpand, +}: DirectoryTreeSelectProps) => { + const [checkedKeys, setCheckedKeys] = useState([]); + const [listHeight, setListHeight] = useState(getListHeight); + + const hierarchicalTreeData = useMemo( + () => flatToHierarchy(treeData), + [treeData] + ); + + const handleTreeSelectDataLoad = useCallback(async ({value: nodeValue}: {value?: unknown}) => { + if ("string" !== typeof nodeValue) { + return; + } + await onDataLoad(nodeValue); + }, [onDataLoad]); + + const handleDirectoryTreeLoadData = useCallback(async (node: {key: React.Key}) => { + await onDataLoad(node.key as string); + }, [onDataLoad]); + + const handleTreeExpand = useCallback((keys: React.Key[]) => { + onExpand(keys as string[]); + }, [onExpand]); + + const handleDirectoryTreeCheck: TreeProps["onCheck"] = useCallback(( + checked: React.Key[] | {checked: React.Key[]; halfChecked: React.Key[]} + ) => { + const keys = Array.isArray(checked) ? + checked : + checked.checked; + + const stringKeys = keys as string[]; + setCheckedKeys(stringKeys); + onChange(stringKeys); + }, [onChange]); + + const handleTreeSelectChange = useCallback((values: string[]) => { + setCheckedKeys(values); + onChange(values); + }, [onChange]); + + const renderPopup = useCallback(() => ( +
+ +
+ ), [ + checkedKeys, + expandedKeys, + handleDirectoryTreeCheck, + handleDirectoryTreeLoadData, + handleTreeExpand, + hierarchicalTreeData, + listHeight, + ]); + + // On initialization, set up window resize listener to adjust list height. + useEffect(() => { + const handleResize = () => { + setListHeight(getListHeight()); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return ( + + ); +}; + + +export default DirectoryTreeSelect; diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/SwitcherIcon.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/SwitcherIcon.tsx deleted file mode 100644 index 14ee8de0ec..0000000000 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/SwitcherIcon.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; - -import { - MinusOutlined, - PlusOutlined, -} from "@ant-design/icons"; -import {TreeSelectProps} from "antd"; - - -type SwitcherIconFn = Exclude; - -type SwitcherIconProps = Parameters>[0]; - - -/** - * Icon component for tree node expand/collapse state. - * - * @param props - * @param props.expanded Whether the node is expanded. - * @return - */ -const SwitcherIcon = ({expanded}: SwitcherIconProps) => (expanded ? - : - ); - - -export default SwitcherIcon; diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css index fe35b5fbca..65948eb73a 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.module.css @@ -1,5 +1,3 @@ -.treeTitle { - cursor: pointer; - display: inline-block; - width: 100%; +.directoryTreePopup { + padding: 4px; } diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx index 3e5a2646de..5be35dc9ee 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx @@ -1,4 +1,4 @@ -import React, { +import { useCallback, useEffect, useRef, @@ -6,15 +6,10 @@ import React, { } from "react"; import {FileEntry} from "@webui/common/schemas/os"; -import { - Form, - TreeSelect, - TreeSelectProps, -} from "antd"; +import {Form} from "antd"; import {listFiles} from "../../../../api/os"; -import styles from "./index.module.css"; -import SwitcherIcon from "./SwitcherIcon"; +import DirectoryTreeSelect from "./DirectoryTreeSelect"; import { ROOT_NODE, ROOT_PATH, @@ -22,27 +17,22 @@ import { } from "./typings"; import { addServerPrefix, - getListHeight, handleLoadError, toTreeNode, } from "./utils"; -type LoadDataNode = Parameters>[0]; - -type TreeExpandKeys = Parameters>[0]; - - /** * Form item with TreeSelect for selecting file paths for compression. - * Supports lazy loading and search with auto-expand. + * Uses DirectoryTree for intuitive folder/file navigation. * * @return + * @see https://ant.design/components/tree#tree-demo-directory */ const PathsSelectFormItem = () => { + const form = Form.useFormInstance(); const [treeData, setTreeData] = useState([ROOT_NODE]); const [expandedKeys, setExpandedKeys] = useState([]); - const [listHeight, setListHeight] = useState(getListHeight); // Use a ref, instead of a state passed to AntD's `treeLoadedKeys`, to dedupe load requests. const loadedPathsRef = useRef(new Set()); @@ -75,6 +65,18 @@ const PathsSelectFormItem = () => { } }, [addNodes]); + const handleChange = useCallback((values: string[]) => { + form.setFieldValue("paths", values); + }, [form]); + + const handleExpand = useCallback((keys: string[]) => { + setExpandedKeys(keys); + }, []); + + const handleLoadData = useCallback(async (path: string) => { + await loadPath(path).catch(handleLoadError); + }, [loadPath]); + // On initialization, load root directory contents. useEffect(() => { loadPath(ROOT_PATH) @@ -84,91 +86,18 @@ const PathsSelectFormItem = () => { .catch(handleLoadError); }, [loadPath]); - const handleLoadData = useCallback(async ({value}: LoadDataNode) => { - if ("string" !== typeof value) { - return; - } - await loadPath(value).catch(handleLoadError); - }, [loadPath]); - - const handleTitleClick = useCallback((nodeData: TreeNode, ev: React.MouseEvent) => { - if (nodeData.isLeaf) { - // Propagate event to let TreeSelect handle selection. - return; - } - ev.stopPropagation(); - - const nodeValue = nodeData.value; - if (expandedKeys.includes(nodeValue)) { - setExpandedKeys(expandedKeys.filter((k) => k !== nodeValue)); - - return; - } - - if (false === loadedPathsRef.current.has(nodeValue)) { - loadPath(nodeValue).catch(handleLoadError); - } - - setExpandedKeys([ - ...expandedKeys, - nodeValue, - ]); - }, [ - expandedKeys, - loadPath, - ]); - - const handleTreeExpand = useCallback((keys: TreeExpandKeys) => { - setExpandedKeys(keys as string[]); - }, []); - - const renderTreeTitle = useCallback((nodeData: TreeNode) => ( - { - handleTitleClick(nodeData, ev); - }} - > - {nodeData.title} - - ), [handleTitleClick]); - - useEffect(() => { - const handleResize = () => { - setListHeight(getListHeight()); - }; - - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - return ( - + onChange={handleChange} + onDataLoad={handleLoadData} + onExpand={handleExpand}/> ); }; diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts index 79bfe56b4b..56c41e0c67 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts @@ -1,5 +1,6 @@ import {FileEntry} from "@webui/common/schemas/os"; import {message} from "antd"; +import type {TreeDataNode} from "antd/es"; import {settings} from "../../../../settings"; import { @@ -111,9 +112,48 @@ const handleLoadError = (e: unknown): void => { "Failed to load path"); }; +/** + * Converts flat tree data (simple mode) to hierarchical format for DirectoryTree. + * + * @param flatData Array of flat tree nodes with pId references + * @return Hierarchical tree structure with children arrays. + */ +const flatToHierarchy = (flatData: TreeNode[]): TreeDataNode[] => { + const nodeMap = new Map(); + const roots: TreeDataNode[] = []; + + // Create all nodes + for (const node of flatData) { + nodeMap.set(node.id, { + key: node.value, + title: node.title, + isLeaf: node.isLeaf, + children: [], + }); + } + + // Build hierarchy + for (const node of flatData) { + const treeNode = nodeMap.get(node.id); + if (null === node.pId) { + if (treeNode) { + roots.push(treeNode); + } + } else { + const parent = nodeMap.get(node.pId); + if (parent?.children && treeNode) { + parent.children.push(treeNode); + } + } + } + + return roots; +}; + export { addServerPrefix, + flatToHierarchy, getListHeight, handleLoadError, ROOT_PATH, From c90c1423ed738f4a164280a9a4bffc071321363b Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Thu, 8 Jan 2026 12:54:46 -0500 Subject: [PATCH 4/6] reflow comment --- .../Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx index 2ab989fc8a..4a32bc8f36 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx @@ -31,8 +31,8 @@ interface DirectoryTreeSelectProps { } /** - * TreeSelect component that uses DirectoryTree for the popup. - * Provides folder/file icons and intuitive directory navigation. + * TreeSelect component that uses DirectoryTree for the popup. Provides folder/file icons and + * intuitive directory navigation. * * @param props * @param props.expandedKeys From 6595015f6533f9f85ada5f57f311dfb7f7655fe7 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Thu, 8 Jan 2026 14:22:07 -0500 Subject: [PATCH 5/6] refactor(webui): Simplify DirectoryTreeSelect structure and integrate optimized key handling functions --- .../DirectoryTreeSelect.tsx | 159 +++++++----------- .../Compress/PathsSelectFormItem/index.tsx | 13 +- .../Compress/PathsSelectFormItem/utils.ts | 71 ++++++++ 3 files changed, 136 insertions(+), 107 deletions(-) diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx index 4a32bc8f36..4655b7e61f 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreeSelect.tsx @@ -1,150 +1,105 @@ -import React, { +import { useCallback, - useEffect, useMemo, - useState, } from "react"; -import { - Tree, - TreeSelect, -} from "antd"; -import type {TreeProps} from "antd/es"; +import {Select} from "antd"; +import type {TreeDataNode} from "antd/es"; -import styles from "./index.module.css"; +import DirectoryTreePopup from "./DirectoryTreePopup"; import {TreeNode} from "./typings"; import { + filterToParents, flatToHierarchy, - getListHeight, + removeWithDescendants, } from "./utils"; -const {DirectoryTree} = Tree; - - interface DirectoryTreeSelectProps { + checkedKeys: string[]; expandedKeys: string[]; treeData: TreeNode[]; - onChange: (values: string[]) => void; - onDataLoad: (path: string) => Promise; + onCheck: (keys: string[]) => void; onExpand: (keys: string[]) => void; + onLoadData: (path: string) => Promise; } /** - * TreeSelect component that uses DirectoryTree for the popup. Provides folder/file icons and - * intuitive directory navigation. + * Renders a select component that uses DirectoryTree for the dropdown. Provides folder/file + * icons and intuitive directory navigation. * * @param props + * @param props.checkedKeys * @param props.expandedKeys * @param props.treeData - * @param props.onChange - * @param props.onDataLoad + * @param props.onCheck * @param props.onExpand + * @param props.onLoadData * @return - * @see https://ant.design/components/tree#tree-demo-directory */ const DirectoryTreeSelect = ({ + checkedKeys, expandedKeys, treeData, - onChange, - onDataLoad, + onCheck, onExpand, + onLoadData, }: DirectoryTreeSelectProps) => { - const [checkedKeys, setCheckedKeys] = useState([]); - const [listHeight, setListHeight] = useState(getListHeight); - - const hierarchicalTreeData = useMemo( + const hierarchicalTreeData: TreeDataNode[] = useMemo( () => flatToHierarchy(treeData), [treeData] ); + const selectOptions = useMemo( + () => treeData.map((node) => ({label: node.value, value: node.value})), + [treeData] + ); + const displayValue = useMemo( + () => filterToParents(treeData, checkedKeys), + [ + checkedKeys, + treeData, + ] + ); - const handleTreeSelectDataLoad = useCallback(async ({value: nodeValue}: {value?: unknown}) => { - if ("string" !== typeof nodeValue) { - return; - } - await onDataLoad(nodeValue); - }, [onDataLoad]); - - const handleDirectoryTreeLoadData = useCallback(async (node: {key: React.Key}) => { - await onDataLoad(node.key as string); - }, [onDataLoad]); - - const handleTreeExpand = useCallback((keys: React.Key[]) => { - onExpand(keys as string[]); - }, [onExpand]); - - const handleDirectoryTreeCheck: TreeProps["onCheck"] = useCallback(( - checked: React.Key[] | {checked: React.Key[]; halfChecked: React.Key[]} - ) => { - const keys = Array.isArray(checked) ? - checked : - checked.checked; - - const stringKeys = keys as string[]; - setCheckedKeys(stringKeys); - onChange(stringKeys); - }, [onChange]); + const handleClear = useCallback(() => { + onCheck([]); + }, [onCheck]); - const handleTreeSelectChange = useCallback((values: string[]) => { - setCheckedKeys(values); - onChange(values); - }, [onChange]); + const handleDeselect = useCallback((value: string) => { + onCheck(removeWithDescendants(treeData, checkedKeys, value)); + }, [ + checkedKeys, + onCheck, + treeData, + ]); - const renderPopup = useCallback(() => ( -
- -
+ const renderDropdown = useCallback(() => ( + ), [ checkedKeys, expandedKeys, - handleDirectoryTreeCheck, - handleDirectoryTreeLoadData, - handleTreeExpand, hierarchicalTreeData, - listHeight, + onCheck, + onExpand, + onLoadData, ]); - // On initialization, set up window resize listener to adjust list height. - useEffect(() => { - const handleResize = () => { - setListHeight(getListHeight()); - }; - - window.addEventListener("resize", handleResize); - - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - return ( - + popupRender={renderDropdown} + value={displayValue} + onClear={handleClear} + onDeselect={handleDeselect}/> ); }; diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx index 5be35dc9ee..d822f05036 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/index.tsx @@ -33,6 +33,7 @@ const PathsSelectFormItem = () => { const form = Form.useFormInstance(); const [treeData, setTreeData] = useState([ROOT_NODE]); const [expandedKeys, setExpandedKeys] = useState([]); + const [checkedKeys, setCheckedKeys] = useState([]); // Use a ref, instead of a state passed to AntD's `treeLoadedKeys`, to dedupe load requests. const loadedPathsRef = useRef(new Set()); @@ -65,8 +66,9 @@ const PathsSelectFormItem = () => { } }, [addNodes]); - const handleChange = useCallback((values: string[]) => { - form.setFieldValue("paths", values); + const handleCheck = useCallback((keys: string[]) => { + setCheckedKeys(keys); + form.setFieldValue("paths", keys); }, [form]); const handleExpand = useCallback((keys: string[]) => { @@ -93,11 +95,12 @@ const PathsSelectFormItem = () => { rules={[{required: true, message: "Please select at least one path"}]} > + onCheck={handleCheck} + onExpand={handleExpand} + onLoadData={handleLoadData}/> ); }; diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts index 56c41e0c67..88b72dd523 100644 --- a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/utils.ts @@ -151,11 +151,82 @@ const flatToHierarchy = (flatData: TreeNode[]): TreeDataNode[] => { }; +/** + * Filters checked keys to only include topmost ancestors. That is, if a node's parent is also + * checked, the node is excluded from the result. + * + * @param treeData + * @param checkedKeys + * @return Filtered keys containing only topmost checked ancestors. + */ +const filterToParents = (treeData: TreeNode[], checkedKeys: string[]): string[] => { + const checkedSet = new Set(checkedKeys); + const nodeMap = new Map(treeData.map((node) => [ + node.id, + node, + ])); + + return checkedKeys.filter((key) => { + // Include if node not found in tree data + if (false === nodeMap.has(key)) { + return true; + } + + const node = nodeMap.get(key) as TreeNode; + + // Exclude if parent is also checked; include otherwise + return null === node.pId || false === checkedSet.has(node.pId); + }); +}; + +/** + * Removes a key and all its descendants from the checked keys. + * + * @param treeData Flat tree data with pId references + * @param checkedKeys Current checked keys + * @param keyToRemove The key to remove along with its descendants + * @return Checked keys with the specified key and descendants removed. + */ +const removeWithDescendants = ( + treeData: TreeNode[], + checkedKeys: string[], + keyToRemove: string +): string[] => { + // Build set of descendants by traversing the tree + const toRemove = new Set([keyToRemove]); + const childrenMap = new Map(); + + for (const node of treeData) { + if (null !== node.pId) { + const children = childrenMap.get(node.pId) ?? []; + children.push(node.id); + childrenMap.set(node.pId, children); + } + } + + // BFS to find all descendants + const queue = [keyToRemove]; + while (0 < queue.length) { + const current = queue[0] as string; + queue.shift(); + const children = childrenMap.get(current) ?? []; + for (const child of children) { + toRemove.add(child); + queue.push(child); + } + } + + return checkedKeys.filter((key) => false === toRemove.has(key)); +}; + + export { addServerPrefix, + filterToParents, flatToHierarchy, getListHeight, handleLoadError, + removeWithDescendants, ROOT_PATH, toTreeNode, }; From 490a748ddf7eac10bba64bdb1173272222b1cae1 Mon Sep 17 00:00:00 2001 From: Junhao Liao Date: Thu, 8 Jan 2026 14:32:36 -0500 Subject: [PATCH 6/6] Add DirectoryTreePopup component for path selection with dynamic resizing and data loading --- .../DirectoryTreePopup.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreePopup.tsx diff --git a/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreePopup.tsx b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreePopup.tsx new file mode 100644 index 0000000000..d09612f641 --- /dev/null +++ b/components/webui/client/src/pages/IngestPage/Compress/PathsSelectFormItem/DirectoryTreePopup.tsx @@ -0,0 +1,97 @@ +import React, { + useCallback, + useEffect, + useState, +} from "react"; + +import {Tree} from "antd"; +import type {TreeDataNode} from "antd/es"; +import type {TreeProps} from "antd/es/tree"; + +import styles from "./index.module.css"; +import {getListHeight} from "./utils"; + + +const {DirectoryTree} = Tree; + + +interface DirectoryTreePopupProps { + checkedKeys: string[]; + expandedKeys: string[]; + treeData: TreeDataNode[]; + onCheck: (keys: string[]) => void; + onExpand: (keys: string[]) => void; + onLoadData: (path: string) => Promise; +} + +/** + * Renders a popup component containing a DirectoryTree for path selection. + * + * @param props + * @param props.checkedKeys + * @param props.expandedKeys + * @param props.treeData + * @param props.onCheck + * @param props.onExpand + * @param props.onLoadData + * @return + */ +const DirectoryTreePopup = ({ + checkedKeys, + expandedKeys, + treeData, + onCheck, + onExpand, + onLoadData, +}: DirectoryTreePopupProps) => { + const [height, setHeight] = useState(getListHeight); + + const handleCheck: TreeProps["onCheck"] = useCallback(( + checked: React.Key[] | {checked: React.Key[]; halfChecked: React.Key[]} + ) => { + const keys = Array.isArray(checked) ? + checked : + checked.checked; + + onCheck(keys as string[]); + }, [onCheck]); + + const handleExpand = useCallback((keys: React.Key[]) => { + onExpand(keys as string[]); + }, [onExpand]); + + const handleLoadData = useCallback(async (node: TreeDataNode) => { + await onLoadData(node.key as string); + }, [onLoadData]); + + useEffect(() => { + const handleResize = () => { + setHeight(getListHeight()); + }; + + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return ( +
+ +
+ ); +}; + + +export default DirectoryTreePopup;