From 67af0c3f552de0bbdaf451e4e5830c2735968371 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 23 Jun 2025 12:27:03 +0530 Subject: [PATCH 01/21] feat: refactor ActionMenuItem and add TrailingItem component for improved rendering --- .../Components/ActionMenu/ActionMenuItem.tsx | 62 +------------------ src/Shared/Components/ActionMenu/types.ts | 51 +-------------- .../TrailingItem/TrailingItem.component.tsx | 61 ++++++++++++++++++ src/Shared/Components/TrailingItem/index.ts | 2 + src/Shared/Components/TrailingItem/types.ts | 57 +++++++++++++++++ 5 files changed, 125 insertions(+), 108 deletions(-) create mode 100644 src/Shared/Components/TrailingItem/TrailingItem.component.tsx create mode 100644 src/Shared/Components/TrailingItem/index.ts create mode 100644 src/Shared/Components/TrailingItem/types.ts diff --git a/src/Shared/Components/ActionMenu/ActionMenuItem.tsx b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx index b140129d1..8d9970c03 100644 --- a/src/Shared/Components/ActionMenu/ActionMenuItem.tsx +++ b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx @@ -2,14 +2,11 @@ import { LegacyRef, MouseEvent, Ref } from 'react' import { Link } from 'react-router-dom' import { Tooltip } from '@Common/Tooltip' -import { ComponentSizeType } from '@Shared/constants' -import { Button, ButtonProps, ButtonVariantType } from '../Button' import { Icon } from '../Icon' -import { NumbersCount } from '../NumbersCount' import { getTooltipProps } from '../SelectPicker/common' -import { DTSwitch, DTSwitchProps } from '../Switch' -import { ActionMenuItemProps, ActionMenuItemType } from './types' +import { TrailingItem } from '../TrailingItem' +import { ActionMenuItemProps } from './types' const COMMON_ACTION_MENU_ITEM_CLASS = 'w-100 flex left top dc__gap-8 py-6 px-8' @@ -48,24 +45,6 @@ export const ActionMenuItem = ({ onClick(item, e) } - const handleTrailingSwitchChange = - ({ type: trailingItemType, config }: ActionMenuItemType['trailingItem']): DTSwitchProps['onChange'] => - (e) => { - if (trailingItemType === 'switch') { - e.stopPropagation() - config.onChange(e) - } - } - - const handleTrailingButtonClick = - ({ type: trailingItemType, config }: ActionMenuItemType['trailingItem']): ButtonProps['onClick'] => - (e) => { - e.stopPropagation() - if (trailingItemType === 'button' && config.onClick) { - config.onClick(e) - } - } - // RENDERERS const renderIcon = (iconProps: typeof startIcon) => iconProps && ( @@ -79,42 +58,7 @@ export const ActionMenuItem = ({ return null } - const { type: trailingItemType, config } = trailingItem - - switch (trailingItemType) { - case 'icon': - return renderIcon(config) - case 'text': { - const { value, icon } = config - return ( - - {value} - {icon && } - - ) - } - case 'counter': - return - case 'switch': - return ( - - ) - case 'button': - return ( - + + {node.trailingItem && } + + + + {isExpanded && ( +
+ {!node.items?.length ? ( + {node.noItemsText || 'No items found.'} + ) : ( +
+ {node.items.map((nodeItem) => ( + + ))} +
+ )} +
+ )} + + ) + } + + const isSelected = selectedId === node.id + + const content = ( + + {node.startIconConfig && ( + + + + )} + + {/* TODO: Tooltip */} + + {/* TODO: Strike through */} + {node.title} + {node.subtitle && {node.subtitle}} + + + ) + + return ( +
+ {node.as === 'link' ? ( + { + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect(node) + }} + > + {content} + + ) : ( + + )} + + {node.trailingItem && } +
+ ) + })} + + ) +} + +export default TreeView diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss new file mode 100644 index 000000000..e8cb71be5 --- /dev/null +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -0,0 +1,72 @@ +.tree-view { + &__container { + &[aria-selected="true"] &--title { + color: var(--B500); + font-weight: 600; + background: var(--B100); + } + } + + &__heading-group-wrapper { + position: relative; + + &--expanded::before { + content: ""; + position: absolute; + left: 17px; + top: 24px; + bottom: 0; + width: 1px; + background-color: var(--N200); + z-index: 0; + } + } + + &__group { + margin-left: 24px; + border-left: 1px solid transparent; + padding-top: 8px; + padding-bottom: 8px; + position: relative; + } + + &__item { + position: relative; + padding-left: 16px; + margin: 4px 0; + + &::before { + content: ""; + position: absolute; + left: -8px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--N200); + z-index: 1; + } + + &::after { + content: ""; + position: absolute; + left: -8px; + top: 0; + width: 1px; + height: 100%; + z-index: 2; + opacity: 0; + background-image: linear-gradient(to bottom, + var(--bg-primary) 0px, + var(--bg-primary) 4px, + var(--B500) 4px, + var(--B500) calc(100% - 4px), + var(--bg-primary) calc(100% - 4px), + var(--bg-primary) 100%); + transition: opacity 0.2s ease; + } + + &:hover::after { + opacity: 1; + } + } +} \ No newline at end of file diff --git a/src/Shared/Components/TreeView/index.ts b/src/Shared/Components/TreeView/index.ts new file mode 100644 index 000000000..f64ae9a11 --- /dev/null +++ b/src/Shared/Components/TreeView/index.ts @@ -0,0 +1 @@ +export { default as TreeView } from './TreeView.component' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts new file mode 100644 index 000000000..87c28fd32 --- /dev/null +++ b/src/Shared/Components/TreeView/types.ts @@ -0,0 +1,78 @@ +import { IconsProps } from '../Icon' +import { TrailingItemProps } from '../TrailingItem' + +// eslint-disable-next-line no-use-before-define +export type TreeNode = TreeHeading | TreeItem + +interface BaseNode { + id: string + /** + * The title of the list item. + */ + title: string + /** + * The subtitle of the list item. + */ + subtitle?: string + /** + * If true, the title will be rendered with line-through. + */ + strikeThrough?: boolean + startIconConfig?: Pick & { + tooltipContent?: string + } + trailingItem?: TrailingItemProps +} + +export interface TreeHeading extends BaseNode { + type: 'heading' + items?: TreeNode[] + /** + * Text to display when there are no items in the list. + * @default 'No items found.' + */ + noItemsText?: string +} + +export type TreeItem = BaseNode & { + type: 'item' + /** + * @default false + */ + isDisabled?: boolean +} & ( + | { + as?: 'button' + /** + * The callback function to handle click events on the button. + */ + onClick?: (e: React.MouseEvent) => void + href?: never + clearQueryParamsOnNavigation?: never + } + | { + as: 'link' + href: string + /** + * The callback function to handle click events on the nav link. + */ + onClick?: (e: React.MouseEvent) => void + /** + * If `true`, clears query parameters during navigation. + * @default false + */ + clearQueryParamsOnNavigation?: boolean + } + ) + +export interface TreeViewProps { + nodes: TreeNode[] + expandedMap: Record + selectedId?: string + onToggle: (item: TreeHeading) => void + onSelect: (item: TreeItem) => void + /** + * WARNING: For internal use only. + */ + depth?: number +} diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index b085ca5bc..25fa4ff67 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -99,6 +99,7 @@ export * from './TargetPlatforms' export * from './Textarea' export * from './ThemeSwitcher' export * from './ToggleResolveScopedVariables' +export * from './TreeView' export * from './Typewriter' export * from './UnsavedChanges' export * from './UnsavedChangesDialog' From 73acec4af231d437b7380d3592b802edda0bf35e Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 25 Jun 2025 15:42:54 +0530 Subject: [PATCH 03/21] fix: css alignment for dividers --- .../TreeView/TreeView.component.tsx | 153 +++++++++++------- src/Shared/Components/TreeView/TreeView.scss | 75 ++------- src/Shared/Components/TreeView/index.ts | 1 + 3 files changed, 108 insertions(+), 121 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 9c87ea710..69127ca1c 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -4,19 +4,52 @@ import { Tooltip } from '@Common/Tooltip' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' -import { TreeViewProps } from './types' +import { TreeHeading, TreeViewProps } from './types' import './TreeView.scss' // Only selected element should have tab-index 0 and for tab navigation use keyboard events const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { const { pathname } = useLocation() + const isFirstLevel = depth === 0 + + const getToggleNode = (node: TreeHeading) => () => { + onToggle(node) + } + return (
{nodes.map((node) => { + const content = ( + + {node.startIconConfig && ( + + + + )} + + {/* TODO: Tooltip */} + + + {node.title} + + {node.subtitle && ( + + {node.subtitle} + + )} + + + ) + if (node.type === 'heading') { const isExpanded = expandedMap[node.id] ?? false @@ -26,48 +59,53 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = role="treeitem" aria-selected={false} aria-expanded={isExpanded} - className={`flexbox-col w-100 tree-view__heading-group-wrapper ${isExpanded ? 'tree-view__heading-group-wrapper--expanded' : ''}`} + className="flexbox-col w-100" aria-level={depth + 1} >
+ {depth > 1 && + Array.from({ length: depth - 1 }).map((_, index) => ( + + + + ))} + {node.trailingItem && } @@ -75,7 +113,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth =
{isExpanded && ( -
+
{!node.items?.length ? ( {node.noItemsText || 'No items found.'} ) : ( @@ -100,39 +138,40 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = } const isSelected = selectedId === node.id + const baseClass = + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 bg__hover--opaque br-4 tree-view__container--item' - const content = ( - - {node.startIconConfig && ( - - - - )} - - {/* TODO: Tooltip */} - - {/* TODO: Strike through */} - {node.title} - {node.subtitle && {node.subtitle}} + const itemDivider = + depth > 0 ? ( + + - - ) + ) : null return (
+ {/* TODO: Duplicate element */} + {depth > 1 && + Array.from({ length: depth - 1 }).map((_, index) => ( + + + + ))} + {node.as === 'link' ? ( { // Prevent navigation to the same page if (node.href === pathname) { @@ -142,18 +181,20 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} > + {itemDivider} {content} ) : ( )} diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss index e8cb71be5..70ee266c4 100644 --- a/src/Shared/Components/TreeView/TreeView.scss +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -1,72 +1,17 @@ .tree-view { &__container { - &[aria-selected="true"] &--title { - color: var(--B500); - font-weight: 600; - background: var(--B100); - } - } - - &__heading-group-wrapper { - position: relative; - - &--expanded::before { - content: ""; - position: absolute; - left: 17px; - top: 24px; - bottom: 0; - width: 1px; - background-color: var(--N200); - z-index: 0; - } - } - - &__group { - margin-left: 24px; - border-left: 1px solid transparent; - padding-top: 8px; - padding-bottom: 8px; - position: relative; - } - - &__item { - position: relative; - padding-left: 16px; - margin: 4px 0; - - &::before { - content: ""; - position: absolute; - left: -8px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--N200); - z-index: 1; - } - - &::after { - content: ""; - position: absolute; - left: -8px; - top: 0; - width: 1px; - height: 100%; - z-index: 2; - opacity: 0; - background-image: linear-gradient(to bottom, - var(--bg-primary) 0px, - var(--bg-primary) 4px, - var(--B500) 4px, - var(--B500) calc(100% - 4px), - var(--bg-primary) calc(100% - 4px), - var(--bg-primary) 100%); - transition: opacity 0.2s ease; + .icon-with-divider { + grid-template-rows: 24px auto; } - &:hover::after { - opacity: 1; + &--item { + &:hover { + .tree-view__divider { + background-color: var(--B500); + height: 16px; + border-radius: 3px; + } + } } } } \ No newline at end of file diff --git a/src/Shared/Components/TreeView/index.ts b/src/Shared/Components/TreeView/index.ts index f64ae9a11..89897deba 100644 --- a/src/Shared/Components/TreeView/index.ts +++ b/src/Shared/Components/TreeView/index.ts @@ -1 +1,2 @@ export { default as TreeView } from './TreeView.component' +export * from './types' From 739b759273ca8751f94c8e6df1f652b4a6472747 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 25 Jun 2025 16:42:28 +0530 Subject: [PATCH 04/21] feat: make variant prop optional in TrailingItemProps and improve TreeView component structure with Divider --- src/Shared/Components/TrailingItem/types.ts | 2 +- .../TreeView/TreeView.component.tsx | 176 +++++++++--------- 2 files changed, 91 insertions(+), 87 deletions(-) diff --git a/src/Shared/Components/TrailingItem/types.ts b/src/Shared/Components/TrailingItem/types.ts index f462af40e..bae6a904b 100644 --- a/src/Shared/Components/TrailingItem/types.ts +++ b/src/Shared/Components/TrailingItem/types.ts @@ -53,5 +53,5 @@ export type TrailingItemProps = TrailingItemType & { /** * @default 'neutral' */ - variant: 'neutral' | 'negative' + variant?: 'neutral' | 'negative' } diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 69127ca1c..c883bdf42 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -8,6 +8,12 @@ import { TreeHeading, TreeViewProps } from './types' import './TreeView.scss' +const Divider = () => ( + + + +) + // Only selected element should have tab-index 0 and for tab navigation use keyboard events const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { const { pathname } = useLocation() @@ -50,6 +56,11 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = ) + const dividerPrefix = + depth > 1 && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: depth - 1 }).map((_, index) => ) + if (node.type === 'heading') { const isExpanded = expandedMap[node.id] ?? false @@ -63,59 +74,56 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = aria-level={depth + 1} >
-
- {depth > 1 && - Array.from({ length: depth - 1 }).map((_, index) => ( - - - - ))} - - + + + + + + {isExpanded && ( + + + + )} + + + {content} + - {node.trailingItem && } + {node.trailingItem && } +
{isExpanded && (
{!node.items?.length ? ( - {node.noItemsText || 'No items found.'} + <> + {dividerPrefix} + + {node.noItemsText || 'No items found.'} + ) : (
{node.items.map((nodeItem) => ( @@ -139,11 +147,11 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = const isSelected = selectedId === node.id const baseClass = - 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 bg__hover--opaque br-4 tree-view__container--item' + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item' const itemDivider = depth > 0 ? ( - + ) : null @@ -156,50 +164,46 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = className="flexbox flex-grow-1 w-100" aria-level={depth + 1} > - {/* TODO: Duplicate element */} - {depth > 1 && - Array.from({ length: depth - 1 }).map((_, index) => ( - - - - ))} - - {node.as === 'link' ? ( - { - // Prevent navigation to the same page - if (node.href === pathname) { - e.preventDefault() + {dividerPrefix} + +
+ {node.as === 'link' ? ( + - {itemDivider} - {content} - - ) : ( - - )} + className={baseClass} + onClick={(e) => { + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect(node) + }} + > + {itemDivider} + {content} + + ) : ( + + )} - {node.trailingItem && } + {node.trailingItem && } +
) })} From 439a4c6895583dc59e221c59dcce559f3190cce3 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 26 Jun 2025 15:34:04 +0530 Subject: [PATCH 05/21] feat: add mode prop to TreeViewProps for navigation and form modes, and update TreeView component for keyboard navigation support --- .../Components/TreeView/TreeView.component.tsx | 12 +++++++++--- src/Shared/Components/TreeView/types.ts | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index c883bdf42..4de6d87bb 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -15,10 +15,12 @@ const Divider = () => ( ) // Only selected element should have tab-index 0 and for tab navigation use keyboard events -const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { +const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0, mode }: TreeViewProps) => { const { pathname } = useLocation() const isFirstLevel = depth === 0 + const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + const getToggleNode = (node: TreeHeading) => () => { onToggle(node) } @@ -82,8 +84,9 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth =
@@ -147,7 +151,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = const isSelected = selectedId === node.id const baseClass = - 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item' + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item dc__select-text' const itemDivider = depth > 0 ? ( @@ -183,6 +187,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = node.onClick?.(e) onSelect(node) }} + tabIndex={isSelected ? 0 : fallbackTabIndex} > {itemDivider} {content} @@ -196,6 +201,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = node.onClick?.(e) onSelect(node) }} + tabIndex={isSelected ? 0 : fallbackTabIndex} > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 87c28fd32..4f52262ae 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -75,4 +75,10 @@ export interface TreeViewProps { * WARNING: For internal use only. */ depth?: number + /** + * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. + * If form mode, will leave the navigation to browser. + * @default 'navigation' + */ + mode: 'navigation' | 'form' } From 5eba693dae3c74ad6ea3d4ba2a4bc9694f3259e7 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 27 Jun 2025 12:27:22 +0530 Subject: [PATCH 06/21] feat: update TreeViewProps to support optional depth, flatNodeList, and getUpdateItemsRefMap, and deprecate CollapsibleList component --- .../CollapsibleList.component.tsx | 3 + .../TreeView/TreeView.component.tsx | 108 +++++++++++++++++- src/Shared/Components/TreeView/types.ts | 31 ++++- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx index e4cde93cf..6a0ce3c87 100644 --- a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx +++ b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx @@ -33,6 +33,9 @@ const renderWithTippy = (tippyProps: TippyProps) => (children: React.ReactElemen ) +/** + * @deprecated - Please use `TreeView` component instead. + */ export const CollapsibleList = ({ config, tabType, diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 4de6d87bb..d765890ba 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,3 +1,4 @@ +import { useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Tooltip } from '@Common/Tooltip' @@ -15,8 +16,21 @@ const Divider = () => ( ) // Only selected element should have tab-index 0 and for tab navigation use keyboard events -const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0, mode }: TreeViewProps) => { +const TreeView = ({ + nodes, + expandedMap, + selectedId, + onToggle, + onSelect, + depth = 0, + mode, + flatNodeList: flatNodeListProp, + getUpdateItemsRefMap: getUpdateItemsRefMapProp, +}: TreeViewProps) => { const { pathname } = useLocation() + // Using this at root level + const rootItemRefs = useRef>({}) + const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 @@ -25,10 +39,78 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onToggle(node) } + const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { + if (!isFirstLevel) { + throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') + } + rootItemRefs.current[id] = el + } + + // will traverse all the nodes that are expanded and visible in the tree view + // and return a flat list of node ids for keyboard navigation + const traverseNodes = (nodeList: typeof nodes): string[] => + nodeList.reduce((acc: string[], node) => { + acc.push(node.id) + if (node.type === 'heading' && expandedMap[node.id] && node.items?.length) { + // If the node is a heading and expanded, traverse its items + acc.push(...traverseNodes(node.items)) + } + return acc + }, []) + + const flatNodeList = useMemo(() => { + if (flatNodeListProp) { + return flatNodeListProp + } + + if (flatNodeListProp) { + // If flatNodeList is provided, return it directly + return flatNodeListProp + } + + return traverseNodes(nodes) + }, [nodes, expandedMap]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (mode !== 'navigation' || !isFirstLevel) { + return + } + + const { key } = e + + if (!['ArrowUp', 'ArrowDown'].includes(key)) { + return + } + + e.preventDefault() + + const target = e.target as HTMLButtonElement | HTMLAnchorElement + const nodeId = target.getAttribute('data-node-id') + if (!nodeId) { + return + } + + // Find the index of the current node in the flatNodeList + const currentIndex = flatNodeList.indexOf(nodeId) + if (currentIndex === -1) { + return + } + + if (key === 'ArrowDown' && currentIndex < flatNodeList.length - 1) { + rootItemRefs.current[flatNodeList[currentIndex + 1]]?.focus() + return + } + + if (key === 'ArrowUp' && currentIndex > 0) { + rootItemRefs.current[flatNodeList[currentIndex - 1]]?.focus() + } + } + return (
{nodes.map((node) => { const content = ( @@ -87,6 +169,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = className="tree-view__container--item dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 dc__select-text" onClick={getToggleNode(node)} tabIndex={fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {depth > 0 && ( @@ -139,6 +227,10 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = nodes={[nodeItem]} depth={depth + 1} mode={mode} + getUpdateItemsRefMap={ + getUpdateItemsRefMapProp || getUpdateItemsRefMap + } + flatNodeList={flatNodeList} /> ))}
@@ -188,6 +280,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {itemDivider} {content} @@ -202,6 +300,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 4f52262ae..309ac0794 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -65,20 +65,39 @@ export type TreeItem = BaseNode & { } ) -export interface TreeViewProps { +export type TreeViewProps = { nodes: TreeNode[] expandedMap: Record selectedId?: string onToggle: (item: TreeHeading) => void onSelect: (item: TreeItem) => void - /** - * WARNING: For internal use only. - */ - depth?: number /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. * @default 'navigation' */ mode: 'navigation' | 'form' -} +} & ( + | { + /** + * WARNING: For internal use only. + */ + depth: number + /** + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. + */ + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + } + | { + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + } +) From 936751552782179289701be0f2415affdaf6e319 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 27 Jun 2025 13:16:35 +0530 Subject: [PATCH 07/21] feat: enhance TreeView component with selected state styling and refactor divider styles --- .../TreeView/TreeView.component.tsx | 9 ++++---- src/Shared/Components/TreeView/TreeView.scss | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index d765890ba..45455a60a 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -15,7 +15,6 @@ const Divider = () => ( ) -// Only selected element should have tab-index 0 and for tab navigation use keyboard events const TreeView = ({ nodes, expandedMap, @@ -113,6 +112,7 @@ const TreeView = ({ {...(isFirstLevel ? { role: 'tree', onKeyDown: handleKeyDown } : {})} > {nodes.map((node) => { + const isSelected = selectedId === node.id const content = ( {node.startIconConfig && ( @@ -163,7 +163,9 @@ const TreeView = ({
{dividerPrefix} -
+
- {node.trailingItem && } + {node.trailingItem && ( +
+ +
+ )}
@@ -278,7 +266,7 @@ const TreeView = ({ e.preventDefault() } node.onClick?.(e) - onSelect(node) + onSelect?.(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} @@ -298,7 +286,7 @@ const TreeView = ({ className={baseClass} onClick={(e) => { node.onClick?.(e) - onSelect(node) + onSelect?.(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} @@ -313,7 +301,11 @@ const TreeView = ({ )} - {node.trailingItem && } + {node.trailingItem && ( +
+ +
+ )}
) diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss index 061aa0441..15f9e045d 100644 --- a/src/Shared/Components/TreeView/TreeView.scss +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -25,5 +25,16 @@ color: var(--B500); } } + + &--title-wrapper:hover { + .title-with-tooltip { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--N300); + text-decoration-thickness: 12%; + text-underline-offset: 20%; + text-decoration-skip-ink: auto; + } + } } } \ No newline at end of file diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx new file mode 100644 index 000000000..48aa9a453 --- /dev/null +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -0,0 +1,94 @@ +import { ConditionalWrap } from '@Common/Helper' +import { Tooltip, TooltipProps, useIsTextTruncated } from '@Common/Tooltip' + +import { Icon } from '../Icon' +import { TreeViewNodeContentProps } from './types' + +const wrapWithTooltip = + (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subTitle: string) => + (children: TooltipProps['children']) => { + if (customTooltipConfig) { + return {children} + } + + if (isTextTruncated) { + return ( + +
+ {title} +
+ {subTitle && ( +

{subTitle}

+ )} +
+ } + interactive + > + {children} + + ) + } + + return children + } + +const TreeViewNodeContent = ({ + startIconConfig, + title, + subtitle, + type, + customTooltipConfig, + strikeThrough, +}: TreeViewNodeContentProps) => { + const { isTextTruncated: isTitleTruncate, handleMouseEnterEvent: handleTitleMouseEnter } = useIsTextTruncated() + const { isTextTruncated: isSubtitleTruncate, handleMouseEnterEvent: handleSubtitleMouseEnter } = + useIsTextTruncated() + + const isTextTruncated = isTitleTruncate || isSubtitleTruncate + + return ( + + {startIconConfig && ( + + {startIconConfig.customIcon || ( + + )} + + )} + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + ) +} + +export default TreeViewNodeContent diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 309ac0794..871821be1 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,3 +1,6 @@ +import { TooltipProps } from '@Common/Tooltip' +import { Never } from '@Shared/types' + import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' @@ -14,13 +17,17 @@ interface BaseNode { * The subtitle of the list item. */ subtitle?: string + customTooltipConfig?: TooltipProps /** * If true, the title will be rendered with line-through. */ strikeThrough?: boolean - startIconConfig?: Pick & { + startIconConfig?: { tooltipContent?: string - } + } & ( + | (Pick & { customIcon?: never }) + | (Never> & { customIcon?: JSX.Element }) + ) trailingItem?: TrailingItemProps } @@ -68,9 +75,9 @@ export type TreeItem = BaseNode & { export type TreeViewProps = { nodes: TreeNode[] expandedMap: Record - selectedId?: string onToggle: (item: TreeHeading) => void - onSelect: (item: TreeItem) => void + selectedId?: string + onSelect?: (item: TreeItem) => void /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. @@ -101,3 +108,8 @@ export type TreeViewProps = { flatNodeList?: never } ) + +export interface TreeViewNodeContentProps + extends Pick { + type: 'heading' | 'item' +} From d0fc33fb84c488cb400bab00619c15f7092f9ba9 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:19:16 +0530 Subject: [PATCH 09/21] feat: integrate framer-motion for animated transitions in TreeView component --- .../TreeView/TreeView.component.tsx | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 746b5ec0a..4ee185687 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,5 +1,6 @@ -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' +import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' @@ -28,15 +29,21 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) + const [transitionNodeId, setTransitionNodeId] = useState(null) const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { + setTransitionNodeId(node.id) onToggle(node) } + const onTransitionEnd = () => { + setTransitionNodeId(null) + } + const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') @@ -197,36 +204,49 @@ const TreeView = ({
- {isExpanded && ( -
- {!node.items?.length ? ( - <> - {dividerPrefix} - - {node.noItemsText || 'No items found.'} - - ) : ( -
- {node.items.map((nodeItem) => ( - - ))} -
- )} -
- )} + + {isExpanded && ( + + {!node.items?.length ? ( + <> + {dividerPrefix} + + + {node.noItemsText || 'No items found.'} + + + ) : ( +
+ {node.items.map((nodeItem) => ( + + ))} +
+ )} +
+ )} +
) } From 7db4a2d25575ce039167c208cf99bb0803c26577 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:40:19 +0530 Subject: [PATCH 10/21] feat: copilot review --- .../TreeView/TreeView.component.tsx | 23 +++++-------------- .../TreeView/TreeViewNodeContent.tsx | 8 +++---- src/Shared/Components/TreeView/constants.ts | 1 + src/Shared/Components/TreeView/types.ts | 2 +- 4 files changed, 12 insertions(+), 22 deletions(-) create mode 100644 src/Shared/Components/TreeView/constants.ts diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 4ee185687..0fe323874 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,9 +1,10 @@ -import { useMemo, useRef, useState } from 'react' +import { useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' +import { DEFAULT_NO_ITEMS_TEXT } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' import { TreeHeading, TreeViewProps } from './types' @@ -29,21 +30,15 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) - const [transitionNodeId, setTransitionNodeId] = useState(null) const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { - setTransitionNodeId(node.id) onToggle(node) } - const onTransitionEnd = () => { - setTransitionNodeId(null) - } - const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') @@ -68,13 +63,8 @@ const TreeView = ({ return flatNodeListProp } - if (flatNodeListProp) { - // If flatNodeList is provided, return it directly - return flatNodeListProp - } - return traverseNodes(nodes) - }, [nodes, expandedMap]) + }, [nodes, expandedMap, flatNodeListProp]) const handleKeyDown = (e: React.KeyboardEvent) => { if (mode !== 'navigation' || !isFirstLevel) { @@ -204,24 +194,23 @@ const TreeView = ({ - + {isExpanded && ( {!node.items?.length ? ( <> {dividerPrefix} - {node.noItemsText || 'No items found.'} + {node.noItemsText || DEFAULT_NO_ITEMS_TEXT} ) : ( diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx index 48aa9a453..f739d1b6d 100644 --- a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -5,7 +5,7 @@ import { Icon } from '../Icon' import { TreeViewNodeContentProps } from './types' const wrapWithTooltip = - (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subTitle: string) => + (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subtitle: string) => (children: TooltipProps['children']) => { if (customTooltipConfig) { return {children} @@ -20,12 +20,12 @@ const wrapWithTooltip = content={
{title}
- {subTitle && ( -

{subTitle}

+ {subtitle && ( +

{subtitle}

)}
} diff --git a/src/Shared/Components/TreeView/constants.ts b/src/Shared/Components/TreeView/constants.ts new file mode 100644 index 000000000..d39c65d46 --- /dev/null +++ b/src/Shared/Components/TreeView/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_NO_ITEMS_TEXT = 'No items found' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 871821be1..8c81b4399 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -36,7 +36,7 @@ export interface TreeHeading extends BaseNode { items?: TreeNode[] /** * Text to display when there are no items in the list. - * @default 'No items found.' + * @default DEFAULT_NO_ITEMS_TEXT */ noItemsText?: string } From 6aa852bf1cbd040fd4474f6bef58075b1e2fc9ab Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:48:16 +0530 Subject: [PATCH 11/21] feat: add click handlers for TreeView node items to manage button and link interactions --- .../TreeView/TreeView.component.tsx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 0fe323874..e6d72e577 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { MouseEvent, useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -6,7 +6,7 @@ import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { TreeHeading, TreeViewProps } from './types' +import { TreeHeading, TreeItem, TreeViewProps } from './types' import './TreeView.scss' @@ -101,6 +101,28 @@ const TreeView = ({ } } + const getNodeItemButtonClick = (node: TreeItem) => (e: MouseEvent) => { + if (node.as !== 'button') { + return + } + + node.onClick?.(e) + onSelect?.(node) + } + + const getNodeItemNavLinkClick = (node: TreeItem) => (e: MouseEvent) => { + if (node.as !== 'link') { + return + } + + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect?.(node) + } + return (
{ - // Prevent navigation to the same page - if (node.href === pathname) { - e.preventDefault() - } - node.onClick?.(e) - onSelect?.(node) - }} + onClick={getNodeItemNavLinkClick(node)} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} ref={ @@ -293,10 +308,7 @@ const TreeView = ({ type="button" disabled={node.isDisabled} className={baseClass} - onClick={(e) => { - node.onClick?.(e) - onSelect?.(node) - }} + onClick={getNodeItemButtonClick(node)} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} ref={ From 029324c7ea9f2d279b52d8ea4f1af930bbd49eea Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:04:44 +0530 Subject: [PATCH 12/21] feat: update TreeView and related components to support selection state and improve click handling --- .../Security/SecurityModal/config/index.ts | 2 +- .../Security/SecurityModal/index.ts | 8 +++++-- .../Security/SecurityModal/types.ts | 7 ------ src/Shared/Components/Security/types.tsx | 7 +++++- .../TrailingItem/TrailingItem.component.tsx | 2 +- src/Shared/Components/TrailingItem/types.ts | 2 +- .../TreeView/TreeView.component.tsx | 22 ++++++++----------- .../TreeView/TreeViewNodeContent.tsx | 3 ++- src/Shared/Components/TreeView/types.ts | 11 ++++++---- 9 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/index.ts b/src/Shared/Components/Security/SecurityModal/config/index.ts index 974736e69..0efecf962 100644 --- a/src/Shared/Components/Security/SecurityModal/config/index.ts +++ b/src/Shared/Components/Security/SecurityModal/config/index.ts @@ -16,5 +16,5 @@ export { getProgressingStateForStatus } from './ImageScan' export { getInfoCardData } from './InfoCard' -export { getSidebarData } from './Sidebar' +export { getSecurityModalSidebarChildFromId, getSecurityModalSidebarId, getSidebarData } from './Sidebar' export { getTableData } from './Table' diff --git a/src/Shared/Components/Security/SecurityModal/index.ts b/src/Shared/Components/Security/SecurityModal/index.ts index 466b11348..e16f6439f 100644 --- a/src/Shared/Components/Security/SecurityModal/index.ts +++ b/src/Shared/Components/Security/SecurityModal/index.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export { getProgressingStateForStatus, getSidebarData } from './config' +export { + getProgressingStateForStatus, + getSecurityModalSidebarChildFromId, + getSecurityModalSidebarId, + getSidebarData, +} from './config' export { CATEGORY_LABELS } from './constants' export { default as SecurityModal } from './SecurityModal' export { getSecurityScan } from './service' @@ -23,7 +28,6 @@ export type { GetResourceScanDetailsResponseType, ScanResultDTO, SidebarDataChildType, - SidebarDataType, SidebarPropsType, } from './types' export { SeveritiesDTO } from './types' diff --git a/src/Shared/Components/Security/SecurityModal/types.ts b/src/Shared/Components/Security/SecurityModal/types.ts index 79e1dac6e..38ec60fc8 100644 --- a/src/Shared/Components/Security/SecurityModal/types.ts +++ b/src/Shared/Components/Security/SecurityModal/types.ts @@ -284,13 +284,6 @@ export type SidebarDataChildType = { } } -export type SidebarDataType = { - label: string - isExpanded: boolean - children: NonNullable - hideInHelmApp?: boolean -} - export type EmptyStateType = Pick export const VulnerabilityState = { diff --git a/src/Shared/Components/Security/types.tsx b/src/Shared/Components/Security/types.tsx index 90a699cab..c7372ee97 100644 --- a/src/Shared/Components/Security/types.tsx +++ b/src/Shared/Components/Security/types.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CATEGORIES, SUB_CATEGORIES } from './SecurityModal/types' +import { CATEGORIES, ScanResultDTO, SUB_CATEGORIES } from './SecurityModal/types' export type ScanCategories = (typeof CATEGORIES)[keyof typeof CATEGORIES] export type ScanSubCategories = (typeof SUB_CATEGORIES)[keyof typeof SUB_CATEGORIES] @@ -37,3 +37,8 @@ export interface SecurityConfigType { codeScan?: SecurityConfigCategoryType kubernetesManifest?: SecurityConfigCategoryType } + +export interface GetSidebarDataParamsType extends Record { + selectedId: string + scanResult: ScanResultDTO +} diff --git a/src/Shared/Components/TrailingItem/TrailingItem.component.tsx b/src/Shared/Components/TrailingItem/TrailingItem.component.tsx index 4e2baac51..649911780 100644 --- a/src/Shared/Components/TrailingItem/TrailingItem.component.tsx +++ b/src/Shared/Components/TrailingItem/TrailingItem.component.tsx @@ -41,7 +41,7 @@ const TrailingItem = ({ type, config, variant = 'neutral' }: TrailingItemProps) ) } case 'counter': - return + return case 'switch': return case 'button': diff --git a/src/Shared/Components/TrailingItem/types.ts b/src/Shared/Components/TrailingItem/types.ts index bae6a904b..d72fa5803 100644 --- a/src/Shared/Components/TrailingItem/types.ts +++ b/src/Shared/Components/TrailingItem/types.ts @@ -28,7 +28,7 @@ export type TrailingItemType = type: 'counter' config: { value: NumbersCountProps['count'] - } + } & Pick } | { type: 'switch' diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index e6d72e577..5cf831211 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, useMemo, useRef } from 'react' +import { SyntheticEvent, useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -23,7 +23,7 @@ const TreeView = ({ onToggle, onSelect, depth = 0, - mode, + mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, }: TreeViewProps) => { @@ -101,26 +101,21 @@ const TreeView = ({ } } - const getNodeItemButtonClick = (node: TreeItem) => (e: MouseEvent) => { - if (node.as !== 'button') { - return - } - + const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { node.onClick?.(e) onSelect?.(node) } - const getNodeItemNavLinkClick = (node: TreeItem) => (e: MouseEvent) => { - if (node.as !== 'link') { - return - } + const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { + commonClickHandler(e, node) + } + const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { // Prevent navigation to the same page if (node.href === pathname) { e.preventDefault() } - node.onClick?.(e) - onSelect?.(node) + commonClickHandler(e, node) } return ( @@ -145,6 +140,7 @@ const TreeView = ({ type={node.type} customTooltipConfig={node.customTooltipConfig} strikeThrough={node.strikeThrough} + isSelected={isSelected} /> ) diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx index f739d1b6d..3c474c970 100644 --- a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -46,6 +46,7 @@ const TreeViewNodeContent = ({ type, customTooltipConfig, strikeThrough, + isSelected, }: TreeViewNodeContentProps) => { const { isTextTruncated: isTitleTruncate, handleMouseEnterEvent: handleTitleMouseEnter } = useIsTextTruncated() const { isTextTruncated: isSubtitleTruncate, handleMouseEnterEvent: handleSubtitleMouseEnter } = @@ -72,7 +73,7 @@ const TreeViewNodeContent = ({ > {title} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 8c81b4399..c8b596929 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,3 +1,5 @@ +import { SyntheticEvent } from 'react' + import { TooltipProps } from '@Common/Tooltip' import { Never } from '@Shared/types' @@ -47,13 +49,13 @@ export type TreeItem = BaseNode & { * @default false */ isDisabled?: boolean -} & ( +} & ( // Should we add as `div` as well? | { as?: 'button' /** * The callback function to handle click events on the button. */ - onClick?: (e: React.MouseEvent) => void + onClick?: (e: SyntheticEvent) => void href?: never clearQueryParamsOnNavigation?: never } @@ -63,7 +65,7 @@ export type TreeItem = BaseNode & { /** * The callback function to handle click events on the nav link. */ - onClick?: (e: React.MouseEvent) => void + onClick?: (e: SyntheticEvent) => void /** * If `true`, clears query parameters during navigation. * @default false @@ -83,7 +85,7 @@ export type TreeViewProps = { * If form mode, will leave the navigation to browser. * @default 'navigation' */ - mode: 'navigation' | 'form' + mode?: 'navigation' | 'form' } & ( | { /** @@ -112,4 +114,5 @@ export type TreeViewProps = { export interface TreeViewNodeContentProps extends Pick { type: 'heading' | 'item' + isSelected: boolean } From fa6b56e888366b3525a43e70df5b1887bbd9b6ee Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:06:15 +0530 Subject: [PATCH 13/21] feat: enhance Sidebar component to support TreeView structure and add threat count handling --- .../Security/SecurityModal/config/Sidebar.ts | 162 +++++++++++++----- 1 file changed, 115 insertions(+), 47 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts index 569f55e3d..9f37f6f7a 100644 --- a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts +++ b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts @@ -14,103 +14,171 @@ * limitations under the License. */ -import { ScanCategoriesWithLicense } from '../../types' +import { TreeItem, TreeViewProps } from '@Shared/Components/TreeView' + +import { GetSidebarDataParamsType } from '../../types' import { CATEGORY_LABELS, SUB_CATEGORY_LABELS } from '../constants' -import { CATEGORIES, SidebarDataType, SUB_CATEGORIES } from '../types' +import { CATEGORIES, SeveritiesDTO, SidebarDataChildType, SUB_CATEGORIES } from '../types' + +export const getSecurityModalSidebarId = ({ category, subCategory }: SidebarDataChildType['value']): string => + JSON.stringify({ category, subCategory }) -export const getSidebarData = (categoriesConfig: Record): SidebarDataType[] => { - const { imageScan, codeScan, kubernetesManifest, imageScanLicenseRisks } = categoriesConfig +export const getSecurityModalSidebarChildFromId = (id: string): SidebarDataChildType['value'] => { + const parsedId = JSON.parse(id) + return { + category: parsedId.category, + subCategory: parsedId.subCategory, + } +} - return [ +export const getSidebarData = ({ + imageScan, + codeScan, + kubernetesManifest, + imageScanLicenseRisks, + selectedId, + scanResult, +}: GetSidebarDataParamsType): TreeViewProps['nodes'] => { + const nodes: TreeViewProps['nodes'] = [ ...(imageScan - ? [ + ? ([ { - label: CATEGORY_LABELS.IMAGE_SCAN, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.IMAGE_SCAN, + id: CATEGORY_LABELS.IMAGE_SCAN, + items: [ { - label: SUB_CATEGORY_LABELS.VULNERABILITIES, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.VULNERABILITIES, + id: getSecurityModalSidebarId({ category: CATEGORIES.IMAGE_SCAN, subCategory: SUB_CATEGORIES.VULNERABILITIES, - }, + }), }, ...(imageScanLicenseRisks - ? [ + ? ([ { - label: SUB_CATEGORY_LABELS.LICENSE, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.LICENSE, + id: getSecurityModalSidebarId({ category: CATEGORIES.IMAGE_SCAN, subCategory: SUB_CATEGORIES.LICENSE, - }, + }), }, - ] + ] satisfies TreeItem[]) : []), ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), ...(codeScan - ? [ + ? ([ { - label: CATEGORY_LABELS.CODE_SCAN, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.CODE_SCAN, + id: CATEGORY_LABELS.CODE_SCAN, + items: [ { - label: SUB_CATEGORY_LABELS.VULNERABILITIES, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.VULNERABILITIES, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.VULNERABILITIES, - }, + }), }, { - label: SUB_CATEGORY_LABELS.LICENSE, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.LICENSE, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.LICENSE, - }, + }), }, { - label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, - }, + }), }, { - label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, - }, + }), }, ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), ...(kubernetesManifest - ? [ + ? ([ { - label: CATEGORY_LABELS.KUBERNETES_MANIFEST, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.KUBERNETES_MANIFEST, + id: CATEGORY_LABELS.KUBERNETES_MANIFEST, + items: [ { - label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + id: getSecurityModalSidebarId({ category: CATEGORIES.KUBERNETES_MANIFEST, subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, - }, + }), }, { - label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + id: getSecurityModalSidebarId({ category: CATEGORIES.KUBERNETES_MANIFEST, subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, - }, + }), }, ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), - ] + ] satisfies TreeViewProps['nodes'] + + // Not implementing complete dfs since its not nested, traversing + nodes.forEach((node) => { + if (node.type === 'heading') { + node.items.forEach((item) => { + if (item.type === 'heading') { + throw new Error( + 'Broken assumption: Heading should not have nested headings in security sidebar, Please implement dfs based handling for nested headings in security sidebar', + ) + } + + const { category, subCategory } = getSecurityModalSidebarChildFromId(item.id) + const subCategoryResult = scanResult[category]?.[subCategory] + + const severities: Partial> = + subCategoryResult?.summary?.severities || subCategoryResult?.misConfSummary?.status + + const threatCount: number = Object.keys(severities || {}).reduce((acc, key) => { + if (key === SeveritiesDTO.SUCCESSES) { + return acc + } + return acc + severities[key] + }, 0) + + // eslint-disable-next-line no-param-reassign + item.trailingItem = threatCount + ? { + type: 'counter', + config: { + value: threatCount, + isSelected: selectedId === item.id, + }, + } + : null + }) + } + }) + + return nodes } From 6445985323b7612fc01b874f6dba8c8e5cd307ec Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:43:50 +0530 Subject: [PATCH 14/21] feat: refactor TreeView component to support uncontrolled mode and update props for better state management --- .../TreeView/TreeView.component.tsx | 18 +++++-- src/Shared/Components/TreeView/types.ts | 52 +++++++++++-------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 5cf831211..e78eddd67 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { SyntheticEvent, useMemo, useRef } from 'react' +import { SyntheticEvent, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -18,7 +18,8 @@ const Divider = () => ( const TreeView = ({ nodes, - expandedMap, + isUncontrolled, + expandedMap: expandedMapProp, selectedId, onToggle, onSelect, @@ -30,13 +31,24 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) + // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) + + const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { - onToggle(node) + if (isUncontrolled) { + setCurrentLevelExpandedMap((prev) => ({ + ...prev, + [node.id]: !prev[node.id], + })) + } else { + onToggle(node) + } } const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index c8b596929..f86823612 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -76,8 +76,6 @@ export type TreeItem = BaseNode & { export type TreeViewProps = { nodes: TreeNode[] - expandedMap: Record - onToggle: (item: TreeHeading) => void selectedId?: string onSelect?: (item: TreeItem) => void /** @@ -88,28 +86,40 @@ export type TreeViewProps = { mode?: 'navigation' | 'form' } & ( | { - /** - * WARNING: For internal use only. - */ - depth: number - /** - * WARNING: For internal use only. - * Would pass this to item button/ref and store it in out ref map through this function. - */ - getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void - - /** - * WARNING: For internal use only. - * List of all nodes visible in tree view for keyboard navigation. - */ - flatNodeList: string[] + isUncontrolled: true + expandedMap?: never + onToggle?: never } | { - depth?: never - getUpdateItemsRefMap?: never - flatNodeList?: never + isUncontrolled?: false + expandedMap: Record + onToggle: (item: TreeHeading) => void } -) +) & + ( + | { + /** + * WARNING: For internal use only. + */ + depth: number + /** + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. + */ + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + } + | { + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + } + ) export interface TreeViewNodeContentProps extends Pick { From 4e9118d3c8a03bc26c372f672486da8ef80cf19b Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 17:22:46 +0530 Subject: [PATCH 15/21] feat: add variant support to TreeView for customizable background and hover styles --- .../Components/TreeView/TreeView.component.tsx | 17 ++++++++++++----- src/Shared/Components/TreeView/constants.ts | 12 ++++++++++++ src/Shared/Components/TreeView/types.ts | 5 +++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index e78eddd67..17368d696 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' -import { DEFAULT_NO_ITEMS_TEXT } from './constants' +import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' import { TreeHeading, TreeItem, TreeViewProps } from './types' @@ -27,6 +27,7 @@ const TreeView = ({ mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, + variant = 'primary', }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level @@ -132,7 +133,7 @@ const TreeView = ({ return (
@@ -169,13 +170,13 @@ const TreeView = ({ aria-level={depth + 1} >
{dividerPrefix}
@@ -290,7 +293,9 @@ const TreeView = ({ > {dividerPrefix} -
+
{node.as === 'link' ? ( {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/constants.ts b/src/Shared/Components/TreeView/constants.ts index d39c65d46..dfa71357c 100644 --- a/src/Shared/Components/TreeView/constants.ts +++ b/src/Shared/Components/TreeView/constants.ts @@ -1 +1,13 @@ +import { TreeViewProps } from './types' + export const DEFAULT_NO_ITEMS_TEXT = 'No items found' + +export const VARIANT_TO_BG_CLASS_MAP: Record = { + primary: 'bg__primary', + secondary: 'bg__secondary', +} + +export const VARIANT_TO_HOVER_CLASS_MAP: Record = { + primary: 'bg__hover--opaque', + secondary: 'bg__hover-secondary--opaque', +} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index f86823612..050eba12d 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -84,6 +84,11 @@ export type TreeViewProps = { * @default 'navigation' */ mode?: 'navigation' | 'form' + /** + * If primary the background color will be bg__primary and bg__hover--opaque, if secondary the background color will be bg__secondary bg__hover-secondary--opaque. + * @default 'primary' + */ + variant?: 'primary' | 'secondary' } & ( | { isUncontrolled: true From e9355d70977162ad86b09f2fa949cc78bd3339a5 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 15:42:19 +0530 Subject: [PATCH 16/21] feat: add scroll to selected item functionality in TreeView component --- .../TreeView/TreeView.component.tsx | 61 +++++++++++++++++-- src/Shared/Components/TreeView/types.ts | 6 ++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 17368d696..9fd96fc27 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -2,11 +2,13 @@ import { SyntheticEvent, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' +import { useEffectAfterMount } from '@Common/Helper' + import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { TreeHeading, TreeItem, TreeViewProps } from './types' +import { TreeHeading, TreeItem, TreeNode, TreeViewProps } from './types' import './TreeView.scss' @@ -28,10 +30,11 @@ const TreeView = ({ flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', + shouldScrollOnChange = true, }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level - const rootItemRefs = useRef>({}) + const itemsRef = useRef>({}) // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) @@ -41,6 +44,54 @@ const TreeView = ({ const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + const findSelectedIdParentNodes = (node: TreeNode, onFindParentNode: (id: string) => void): boolean => { + if (node.id === selectedId) { + return true + } + + if (node.type === 'heading' && node.items?.length) { + let found = false + node.items.forEach((childNode) => { + if (findSelectedIdParentNodes(childNode, onFindParentNode)) { + found = true + onFindParentNode(node.id) + } + }) + return found + } + + return false + } + + const getSelectedIdParentNodes = (): string[] => { + const selectedIdParentNodes: string[] = [] + + nodes.forEach((node) => { + findSelectedIdParentNodes(node, (id: string) => { + selectedIdParentNodes.push(id) + }) + }) + return selectedIdParentNodes + } + + useEffectAfterMount(() => { + // To use this functionality one must make sure the expandedMap is set correctly + if (isFirstLevel && itemsRef.current && itemsRef.current[selectedId]) { + // In case of uncontrolled tree view, we will expand all the parent nodes of the selected item + if (isUncontrolled) { + const selectedIdParentNodes = getSelectedIdParentNodes() + setCurrentLevelExpandedMap((prev) => { + const newExpandedMap = { ...prev } + selectedIdParentNodes.forEach((id) => { + newExpandedMap[id] = true + }) + return newExpandedMap + }) + } + itemsRef.current[selectedId].scrollIntoView() + } + }, [shouldScrollOnChange, selectedId]) + const getToggleNode = (node: TreeHeading) => () => { if (isUncontrolled) { setCurrentLevelExpandedMap((prev) => ({ @@ -56,7 +107,7 @@ const TreeView = ({ if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') } - rootItemRefs.current[id] = el + itemsRef.current[id] = el } // will traverse all the nodes that are expanded and visible in the tree view @@ -105,12 +156,12 @@ const TreeView = ({ } if (key === 'ArrowDown' && currentIndex < flatNodeList.length - 1) { - rootItemRefs.current[flatNodeList[currentIndex + 1]]?.focus() + itemsRef.current[flatNodeList[currentIndex + 1]]?.focus() return } if (key === 'ArrowUp' && currentIndex > 0) { - rootItemRefs.current[flatNodeList[currentIndex - 1]]?.focus() + itemsRef.current[flatNodeList[currentIndex - 1]]?.focus() } } diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 050eba12d..162d479c0 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -89,6 +89,12 @@ export type TreeViewProps = { * @default 'primary' */ variant?: 'primary' | 'secondary' + /** + * If true, means on change of selectedId, the tree view will scroll to the selected item. + * Assumption: parents of the selected item are expanded. + * @default true + */ + shouldScrollOnChange?: boolean } & ( | { isUncontrolled: true From 5e6c67db59de3bfa4f3721871fd77bdd603512c5 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 16:49:46 +0530 Subject: [PATCH 17/21] feat: add defaultExpandedMap prop to TreeView and update state initialization --- src/Shared/Components/TreeView/TreeView.component.tsx | 6 +++++- src/Shared/Components/TreeView/types.ts | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 9fd96fc27..86f506082 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -31,12 +31,13 @@ const TreeView = ({ getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', shouldScrollOnChange = true, + defaultExpandedMap = {}, }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level const itemsRef = useRef>({}) // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views - const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>(defaultExpandedMap) const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp @@ -241,6 +242,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } + {...node.dataAttributes} > {depth > 0 && ( @@ -364,6 +366,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } + {...node.dataAttributes} > {itemDivider} {content} @@ -382,6 +385,7 @@ const TreeView = ({ : getUpdateItemsRefMap(node.id) } data-testid={`tree-view-item-${node.title}`} + {...node.dataAttributes} > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 162d479c0..f1e959505 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,7 +1,7 @@ import { SyntheticEvent } from 'react' import { TooltipProps } from '@Common/Tooltip' -import { Never } from '@Shared/types' +import { DataAttributes, Never } from '@Shared/types' import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' @@ -31,6 +31,7 @@ interface BaseNode { | (Never> & { customIcon?: JSX.Element }) ) trailingItem?: TrailingItemProps + dataAttributes?: Exclude } export interface TreeHeading extends BaseNode { @@ -98,6 +99,10 @@ export type TreeViewProps = { } & ( | { isUncontrolled: true + /** + * @default {} + */ + defaultExpandedMap?: Record expandedMap?: never onToggle?: never } @@ -105,6 +110,7 @@ export type TreeViewProps = { isUncontrolled?: false expandedMap: Record onToggle: (item: TreeHeading) => void + defaultExpandedMap?: never } ) & ( From d31006ec444c978ffc6d510a0d6d3025d0fd2064 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 21:05:12 +0530 Subject: [PATCH 18/21] feat: remove isExpanded property from K8SObjectBaseType interface --- src/Pages/ResourceBrowser/ResourceBrowser.Types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts b/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts index 107b3af21..f5069541c 100644 --- a/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts +++ b/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts @@ -38,7 +38,6 @@ export interface ApiResourceType { export interface K8SObjectBaseType { name: string - isExpanded: boolean } interface K8sRequestResourceIdentifierType { From 443d6e47ee0d2e952e5144bc6ab245d491650637 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 3 Jul 2025 13:09:39 +0530 Subject: [PATCH 19/21] feat: add generic support for DataAttributeType in TreeView component and related types --- .../TreeView/TreeView.component.tsx | 19 +++++++------ src/Shared/Components/TreeView/types.ts | 27 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 86f506082..ecbe6e324 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -18,7 +18,7 @@ const Divider = () => ( ) -const TreeView = ({ +const TreeView = ({ nodes, isUncontrolled, expandedMap: expandedMapProp, @@ -32,7 +32,7 @@ const TreeView = ({ variant = 'primary', shouldScrollOnChange = true, defaultExpandedMap = {}, -}: TreeViewProps) => { +}: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level const itemsRef = useRef>({}) @@ -45,7 +45,10 @@ const TreeView = ({ const fallbackTabIndex = mode === 'navigation' ? -1 : 0 - const findSelectedIdParentNodes = (node: TreeNode, onFindParentNode: (id: string) => void): boolean => { + const findSelectedIdParentNodes = ( + node: TreeNode, + onFindParentNode: (id: string) => void, + ): boolean => { if (node.id === selectedId) { return true } @@ -93,7 +96,7 @@ const TreeView = ({ } }, [shouldScrollOnChange, selectedId]) - const getToggleNode = (node: TreeHeading) => () => { + const getToggleNode = (node: TreeHeading) => () => { if (isUncontrolled) { setCurrentLevelExpandedMap((prev) => ({ ...prev, @@ -166,16 +169,16 @@ const TreeView = ({ } } - const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { + const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { node.onClick?.(e) onSelect?.(node) } - const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { + const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { commonClickHandler(e, node) } - const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { + const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { // Prevent navigation to the same page if (node.href === pathname) { e.preventDefault() @@ -242,7 +245,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {depth > 0 && ( diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index f1e959505..587878555 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -7,9 +7,9 @@ import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' // eslint-disable-next-line no-use-before-define -export type TreeNode = TreeHeading | TreeItem +export type TreeNode = TreeHeading | TreeItem -interface BaseNode { +type BaseNode = { id: string /** * The title of the list item. @@ -31,12 +31,17 @@ interface BaseNode { | (Never> & { customIcon?: JSX.Element }) ) trailingItem?: TrailingItemProps - dataAttributes?: Exclude -} +} & (DataAttributeType extends DataAttributes + ? { + dataAttributes?: DataAttributeType + } + : { + dataAttributes?: never + }) -export interface TreeHeading extends BaseNode { +export type TreeHeading = BaseNode & { type: 'heading' - items?: TreeNode[] + items?: TreeNode[] /** * Text to display when there are no items in the list. * @default DEFAULT_NO_ITEMS_TEXT @@ -44,7 +49,7 @@ export interface TreeHeading extends BaseNode { noItemsText?: string } -export type TreeItem = BaseNode & { +export type TreeItem = BaseNode & { type: 'item' /** * @default false @@ -75,10 +80,10 @@ export type TreeItem = BaseNode & { } ) -export type TreeViewProps = { - nodes: TreeNode[] +export type TreeViewProps = { + nodes: TreeNode[] selectedId?: string - onSelect?: (item: TreeItem) => void + onSelect?: (item: TreeItem) => void /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. @@ -109,7 +114,7 @@ export type TreeViewProps = { | { isUncontrolled?: false expandedMap: Record - onToggle: (item: TreeHeading) => void + onToggle: (item: TreeHeading) => void defaultExpandedMap?: never } ) & From f5469f01ac6540b3bb9d1e408557f2c3bbc279bf Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 3 Jul 2025 15:36:47 +0530 Subject: [PATCH 20/21] feat: enhance TreeView component with controlled state management and improved scrolling behavior --- .../TreeView/TreeView.component.tsx | 99 ++++++++++++------- src/Shared/Components/TreeView/types.ts | 71 ++++++------- 2 files changed, 98 insertions(+), 72 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index ecbe6e324..3c848d105 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,9 +1,7 @@ -import { SyntheticEvent, useMemo, useRef, useState } from 'react' +import { SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { useEffectAfterMount } from '@Common/Helper' - import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' @@ -20,31 +18,21 @@ const Divider = () => ( const TreeView = ({ nodes, - isUncontrolled, - expandedMap: expandedMapProp, + isControlled = false, + expandedMap: expandedMapProp = {}, selectedId, - onToggle, + onToggle: onToggleProp, onSelect, depth = 0, mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', - shouldScrollOnChange = true, defaultExpandedMap = {}, }: TreeViewProps) => { const { pathname } = useLocation() - // Using this at root level - const itemsRef = useRef>({}) - // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views - const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>(defaultExpandedMap) - - const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp - const isFirstLevel = depth === 0 - const fallbackTabIndex = mode === 'navigation' ? -1 : 0 - const findSelectedIdParentNodes = ( node: TreeNode, onFindParentNode: (id: string) => void, @@ -70,6 +58,10 @@ const TreeView = ({ const getSelectedIdParentNodes = (): string[] => { const selectedIdParentNodes: string[] = [] + if (!selectedId) { + return selectedIdParentNodes + } + nodes.forEach((node) => { findSelectedIdParentNodes(node, (id: string) => { selectedIdParentNodes.push(id) @@ -78,32 +70,63 @@ const TreeView = ({ return selectedIdParentNodes } - useEffectAfterMount(() => { - // To use this functionality one must make sure the expandedMap is set correctly + const getDefaultExpandedMap = (): Record => { + const defaultMap: Record = defaultExpandedMap + if (!selectedId) { + return defaultMap + } + + const selectedIdParentNodes = getSelectedIdParentNodes() + selectedIdParentNodes.forEach((id) => { + defaultMap[id] = true + }) + return defaultMap + } + + const [itemIdToScroll, setItemIdToScroll] = useState(null) + + // Using this at root level + const itemsRef = useRef>({}) + // This will in actuality be used in first level of tree view since we are sending isControlled prop as true to all the nested tree views + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = + useState>(getDefaultExpandedMap) + + const expandedMap = isControlled ? expandedMapProp : currentLevelExpandedMap + + const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + + useEffect(() => { + // isControlled is false for first level of the tree view so should set the expanded map only from first level if (isFirstLevel && itemsRef.current && itemsRef.current[selectedId]) { - // In case of uncontrolled tree view, we will expand all the parent nodes of the selected item - if (isUncontrolled) { - const selectedIdParentNodes = getSelectedIdParentNodes() - setCurrentLevelExpandedMap((prev) => { - const newExpandedMap = { ...prev } - selectedIdParentNodes.forEach((id) => { - newExpandedMap[id] = true - }) - return newExpandedMap + const selectedIdParentNodes = getSelectedIdParentNodes() + setCurrentLevelExpandedMap((prev) => { + const newExpandedMap = { ...prev } + selectedIdParentNodes.forEach((id) => { + newExpandedMap[id] = true }) - } - itemsRef.current[selectedId].scrollIntoView() + return newExpandedMap + }) + + setItemIdToScroll(selectedId) } - }, [shouldScrollOnChange, selectedId]) + }, [selectedId]) const getToggleNode = (node: TreeHeading) => () => { - if (isUncontrolled) { + if (isControlled) { + onToggleProp(node) + } else { setCurrentLevelExpandedMap((prev) => ({ ...prev, [node.id]: !prev[node.id], })) + } + } + + const childItemsOnToggle = (node: TreeHeading) => { + if (isControlled) { + onToggleProp(node) } else { - onToggle(node) + getToggleNode(node)() } } @@ -111,6 +134,15 @@ const TreeView = ({ if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') } + + if (id === itemIdToScroll) { + el?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }) + setItemIdToScroll(null) + } itemsRef.current[id] = el } @@ -308,7 +340,8 @@ const TreeView = ({ key={nodeItem.id} expandedMap={expandedMap} selectedId={selectedId} - onToggle={onToggle} + isControlled + onToggle={childItemsOnToggle} onSelect={onSelect} nodes={[nodeItem]} depth={depth + 1} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 587878555..fcae396c9 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -96,52 +96,45 @@ export type TreeViewProps = { */ variant?: 'primary' | 'secondary' /** - * If true, means on change of selectedId, the tree view will scroll to the selected item. - * Assumption: parents of the selected item are expanded. - * @default true + * @default {} */ - shouldScrollOnChange?: boolean -} & ( + defaultExpandedMap?: Record +} /** + * WARNING: For internal use only. + */ & ( | { - isUncontrolled: true + depth: number /** - * @default {} + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. */ - defaultExpandedMap?: Record - expandedMap?: never - onToggle?: never + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + + /** + * Would be called when the user toggles a heading. + */ + onToggle: (item: TreeHeading) => void + /** + * Map of id to whether the item is expanded or not. + */ + expandedMap: Record + isControlled: true } | { - isUncontrolled?: false - expandedMap: Record - onToggle: (item: TreeHeading) => void - defaultExpandedMap?: never + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + onToggle?: never + expandedMap?: never + isControlled?: false } -) & - ( - | { - /** - * WARNING: For internal use only. - */ - depth: number - /** - * WARNING: For internal use only. - * Would pass this to item button/ref and store it in out ref map through this function. - */ - getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void - - /** - * WARNING: For internal use only. - * List of all nodes visible in tree view for keyboard navigation. - */ - flatNodeList: string[] - } - | { - depth?: never - getUpdateItemsRefMap?: never - flatNodeList?: never - } - ) +) export interface TreeViewNodeContentProps extends Pick { From 841056a9e3b582d4e8f0d5425d0cc0e0af50d81f Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 4 Jul 2025 00:54:01 +0530 Subject: [PATCH 21/21] fix: handle undefined dataAttributes in TreeView component to prevent potential errors --- .../Security/SecurityModal/config/Sidebar.ts | 35 ++++++++++++------- .../TreeView/TreeView.component.tsx | 4 +-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts index 9f37f6f7a..b5b225688 100644 --- a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts +++ b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts @@ -144,9 +144,9 @@ export const getSidebarData = ({ ] satisfies TreeViewProps['nodes'] // Not implementing complete dfs since its not nested, traversing - nodes.forEach((node) => { + const parsedNodes = nodes.map<(typeof nodes)[number]>((node) => { if (node.type === 'heading') { - node.items.forEach((item) => { + const items = node.items.map<(typeof node.items)[number]>((item) => { if (item.type === 'heading') { throw new Error( 'Broken assumption: Heading should not have nested headings in security sidebar, Please implement dfs based handling for nested headings in security sidebar', @@ -166,19 +166,28 @@ export const getSidebarData = ({ return acc + severities[key] }, 0) - // eslint-disable-next-line no-param-reassign - item.trailingItem = threatCount - ? { - type: 'counter', - config: { - value: threatCount, - isSelected: selectedId === item.id, - }, - } - : null + return { + ...item, + trailingItem: threatCount + ? { + type: 'counter', + config: { + value: threatCount, + isSelected: selectedId === item.id, + }, + } + : null, + } }) + + return { + ...node, + items, + } } + + return node }) - return nodes + return parsedNodes } diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 3c848d105..3aaddd479 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -402,7 +402,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {itemDivider} {content} @@ -421,7 +421,7 @@ const TreeView = ({ : getUpdateItemsRefMap(node.id) } data-testid={`tree-view-item-${node.title}`} - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {itemDivider} {content}