diff --git a/src/common/JobTree.jsx b/src/common/JobTree.jsx new file mode 100644 index 000000000..65bf99f1b --- /dev/null +++ b/src/common/JobTree.jsx @@ -0,0 +1,446 @@ +import { + useState, + useCallback, + useMemo, + createContext, + useContext, + useImperativeHandle, +} from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +// Tree Selection Context +const TreeSelectionContext = createContext({ + selectedNodes: new Set(), + isSelectable: false, + selectNode: () => {}, + isSelected: () => false, +}); + +// Tree Selection Provider +const TreeSelectionProvider = ({ + children, + selectable = false, + onSelectNodes, +}) => { + const [selectedNodes, setSelectedNodes] = useState(new Set()); + + const selectNode = (nodeData, isSelected, clearOthers = false) => { + if (!selectable) return; + + setSelectedNodes((prevSelected) => { + const newSelected = clearOthers ? new Set() : new Set(prevSelected); + + if (isSelected) { + newSelected.add(nodeData); + } else { + newSelected.delete(nodeData); + } + + // Trigger callback with array of selected node IDs + if (onSelectNodes) { + onSelectNodes(Array.from(newSelected)); + } + + return newSelected; + }); + }; + + const isSelected = (node) => { + return selectedNodes.has(node); + }; + + const contextValue = useMemo( + () => ({ + selectedNodes, + isSelectable: selectable, + selectNode, + isSelected, + }), + [selectedNodes, selectable, selectNode, isSelected] + ); + + return ( + + {children} + + ); +}; + +// Hook to use tree selection context +const useTreeSelection = () => { + const context = useContext(TreeSelectionContext); + if (!context) { + throw new Error( + "useTreeSelection must be used within a TreeSelectionProvider" + ); + } + return context; +}; + +// Utility function to count leaf nodes in a subtree +const countLeafNodes = (node) => { + if (!node.children || node.children.length === 0) { + return 1; // This is a leaf node + } + + return node.children.reduce((count, child) => { + return count + countLeafNodes(child); + }, 0); +}; + +// Default title renderer - handles different data types gracefully +const _defaultNodeTitleRender = ({ data }) => { + if (typeof data === "string") { + return {data}; + } + if (data && typeof data === "object") { + // Display name, title, or id if available, otherwise fallback to JSON + const displayText = + data.name || data.title || data.label || data.id || JSON.stringify(data); + return {displayText}; + } + return {String(data)}; +}; + +const TitleTreeNode = ({ + data, + titleRenderFn = _defaultNodeTitleRender, + firstIcon, + secondIcon, + onClick, + onDoubleClick, +}) => { + const { isSelectable, selectNode, isSelected } = useTreeSelection(); + const nodeIsSelected = isSelected(data); + + const handleClick = (e) => { + if (isSelectable && (e.ctrlKey || e.metaKey)) { + // Multi-select with Ctrl/Cmd key + e.stopPropagation(); + selectNode(data, !nodeIsSelected); + } else if (isSelectable && e.shiftKey) { + // Select with Shift key + // TODO: Range selection could be implemented later + e.stopPropagation(); + selectNode(data, !nodeIsSelected); + } else if (isSelectable && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + // Single select + e.stopPropagation(); + selectNode(data, true, true); // true for clearOthers + } + + onClick && onClick(e); + }; + + return ( + + {firstIcon} + {secondIcon} + + {titleRenderFn({ data })} + + + ); +}; + +// Leaf node component for nodes without children +const LeafTreeNode = ({ data, titleRenderFn = _defaultNodeTitleRender }) => { + return ( +
  • + } + secondIcon={} + /> +
  • + ); +}; + +// Component for nodes that support lazy loading of children +const LazyExpandableTreeNode = ({ + data, + titleRenderFn = _defaultNodeTitleRender, + onLoadChildren, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [children, setChildren] = useState(data.children || []); + const [loadError, setLoadError] = useState(null); + const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false); + + const toggleExpand = useCallback( + async (e) => { + e.stopPropagation(); + if (!isExpanded) { + // Expanding - load children if needed + if ( + !hasAttemptedLoad && + onLoadChildren && + (!data.children || data.children.length === 0) + ) { + setIsLoading(true); + setLoadError(null); + try { + const loadedChildren = await onLoadChildren(data); + setChildren(loadedChildren || []); + setHasAttemptedLoad(true); + } catch (error) { + setLoadError(error.message || "Failed to load children"); + console.error("Error loading children:", error); + } finally { + setIsLoading(false); + } + } + } + setIsExpanded(!isExpanded); + }, + [isExpanded, onLoadChildren, data, hasAttemptedLoad] + ); + + const hasChildren = children && children.length > 0; + const canExpand = hasChildren || (onLoadChildren && !hasAttemptedLoad); + + return ( +
  • + + + {canExpand ? ( + + ) : ( + + )} + + {titleRenderFn({ data })} + {isLoading && ( + + + + )} + + + {isExpanded && ( +
      + {loadError ? ( +
    • + + {loadError} +
    • + ) : ( + children.map((child) => ( + + )) + )} +
    + )} +
  • + ); +}; + +// Regular expandable node for nodes with pre-loaded children +const ExpandableTreeNode = ({ + data, + titleRenderFn = _defaultNodeTitleRender, + onLoadChildren, +}) => { + const [isExpanded, setIsExpanded] = useState(data?.expanded); + + const toggleExpand = (e) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + const hasChildren = useMemo( + () => data.children && data.children.length > 0, + [data] + ); + + if (!hasChildren) { + return ; + } + + // Calculate leaf count for this subtree + const leafCount = useMemo(() => countLeafNodes(data), [data]); + + return ( +
  • + + } + secondIcon={ + + + {leafCount < 100 ? leafCount : "99+"} + + + } + /> + + + {isExpanded && ( + + {data.children.map((child) => ( + + ))} + + )} + +
  • + ); +}; + +// Generic tree node that determines which type of node to render +const TreeNode = ({ data, titleRenderFn, onLoadChildren }) => { + // Check if this node supports lazy loading + const supportsLazyLoading = useMemo(() => { + return ( + data.hasChildren || + data.loadChildren || + (onLoadChildren && + (data.children === undefined || data.children === null)) + ); + }, [data, onLoadChildren]); + + // Check if this node has children or can have children + const hasChildren = useMemo( + () => data.children && data.children.length > 0, + [data] + ); + const canHaveChildren = hasChildren || supportsLazyLoading; + + if (!canHaveChildren) { + return ; + } + + if (supportsLazyLoading) { + return ( + + ); + } + + return ( + + ); +}; + +// Internal Tree component that renders the actual tree +const TreeContent = ({ + nodes, + titleRenderFn = _defaultNodeTitleRender, + onLoadChildren, +}) => { + if (!nodes || nodes.length === 0) { + return ( +
    No items to display
    + ); + } + + return ( +
      + {nodes.map((node) => ( + + ))} +
    + ); +}; + +// Main Tree component +const Tree = ({ + ref, + nodes, + selectable = false, + onSelectNodes, + titleRenderFn = _defaultNodeTitleRender, + onLoadChildren, +}) => { + const expandAll = () => { + // Logic to expand/collapse all nodes + }; + const collapseAll = () => { + // Logic to expand/collapse all nodes + }; + // Expose a method to expand/collapse all nodes could be added here + useImperativeHandle(ref, () => ({ + expandAll, + collapseAll, + })); + + return ( + + + + ); +}; + +export default Tree; diff --git a/src/layout/Router.jsx b/src/layout/Router.jsx index 2696f4842..55795799e 100644 --- a/src/layout/Router.jsx +++ b/src/layout/Router.jsx @@ -28,6 +28,7 @@ import { useEffect } from "react"; import Footer from "./Footer"; import TopAnnouncement from "./TopAnnouncement"; import UserMetricsPage from "../pages/UserMetricsPage"; +import ExperimentTreeAlpha from "../pages/ExperimentTreeAlpha"; const router = createBrowserRouter( [ @@ -79,6 +80,10 @@ const router = createBrowserRouter( path: "/experiment/:expid/tree", element: , }, + { + path: "/experiment/:expid/tree-alpha", + element: , + }, { path: "/experiment/:expid/graph", element: , diff --git a/src/pages/ExperimentTreeAlpha.jsx b/src/pages/ExperimentTreeAlpha.jsx new file mode 100644 index 000000000..8a750c5df --- /dev/null +++ b/src/pages/ExperimentTreeAlpha.jsx @@ -0,0 +1,106 @@ +import { useParams } from "react-router-dom"; +import { autosubmitApiV3 } from "../services/autosubmitApiV3"; +import Tree from "../common/JobTree"; +import { useEffect, useMemo, useState } from "react"; + +const _customTitleRender = ({ data }) => { + return ( +
    + ); +}; + +const ExperimentTreeAlpha = () => { + const routeParams = useParams(); + const [filter, setFilter] = useState(""); + const [treeData, setTreeData] = useState(null); + + const { data, isFetching, refetch, isError } = + autosubmitApiV3.endpoints.getExperimentTreeView.useQuery({ + expid: routeParams.expid, + runId: null, + }); + + useEffect(() => { + if (data && data.tree) { + if (filter && filter.length > 0) { + // Apply filter to the tree data + const applyFilter = (nodes) => { + return nodes + .map((node) => { + if (node.children) { + const filteredChildren = applyFilter(node.children); + if ( + filteredChildren.length > 0 || + node.title.includes(filter) + ) { + return { ...node, children: filteredChildren }; + } + return null; + } else if (node.title.includes(filter)) { + return node; + } + return null; + }) + .filter((node) => node !== null); + }; + const filteredTree = applyFilter(data.tree); + setTreeData(filteredTree); + return; + } + setTreeData(data.tree); + return; + } + setTreeData(null); + return; + }, [data, filter]); + + const handleNodeSelect = (selectedNodeIds) => { + console.log("Selected node IDs:", selectedNodeIds); + }; + + const handleModify = () => { + setTreeData([ + { refKey: Date.now(), title: "New node" }, + ...treeData, + // { refKey: Date.now(), title: "New Node" }, + ]); + }; + return ( +
    + + + {isFetching && ( +
    Loading...
    + )} + {isError && ( +
    Error loading tree data.
    + )} + {treeData && ( + + )} +
    + ); +}; +export default ExperimentTreeAlpha;