diff --git a/package-lock.json b/package-lock.json index 91d5373e2..eacee860c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-4", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1076e2ab1..4622be364 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-4", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Common/Hooks/UseIsTextTruncated/UseIsTextTruncated.ts b/src/Common/Hooks/UseIsTextTruncated/UseIsTextTruncated.ts new file mode 100644 index 000000000..c39bf7962 --- /dev/null +++ b/src/Common/Hooks/UseIsTextTruncated/UseIsTextTruncated.ts @@ -0,0 +1,26 @@ +import { useState } from 'react' + +import { SUB_PIXEL_ERROR } from './constants' + +const useIsTextTruncated = () => { + const [isTextTruncated, setIsTextTruncated] = useState(false) + + const handleMouseEnterEvent: React.MouseEventHandler = (event) => { + const { currentTarget: node } = event + const isTextOverflowing = + node.scrollWidth > node.clientWidth + SUB_PIXEL_ERROR || + node.scrollHeight > node.clientHeight + SUB_PIXEL_ERROR + if (isTextOverflowing && !isTextTruncated) { + setIsTextTruncated(true) + } else if (!isTextOverflowing && isTextTruncated) { + setIsTextTruncated(false) + } + } + + return { + isTextTruncated, + handleMouseEnterEvent, + } +} + +export default useIsTextTruncated diff --git a/src/Common/Hooks/UseIsTextTruncated/constants.ts b/src/Common/Hooks/UseIsTextTruncated/constants.ts new file mode 100644 index 000000000..377520560 --- /dev/null +++ b/src/Common/Hooks/UseIsTextTruncated/constants.ts @@ -0,0 +1 @@ +export const SUB_PIXEL_ERROR = 1 diff --git a/src/Common/Hooks/UseIsTextTruncated/index.ts b/src/Common/Hooks/UseIsTextTruncated/index.ts new file mode 100644 index 000000000..9bc03650e --- /dev/null +++ b/src/Common/Hooks/UseIsTextTruncated/index.ts @@ -0,0 +1 @@ +export { default as useIsTextTruncated } from './UseIsTextTruncated' diff --git a/src/Common/Hooks/index.ts b/src/Common/Hooks/index.ts index fc57ae76b..b6bfc1049 100644 --- a/src/Common/Hooks/index.ts +++ b/src/Common/Hooks/index.ts @@ -16,6 +16,7 @@ export { useClickOutside } from './UseClickOutside/UseClickOutside' export { useGetUserRoles } from './UseGetUserRoles' +export { useIsTextTruncated } from './UseIsTextTruncated' export * from './UseRegisterShortcut' export * from './useStateFilters' export * from './useUrlFilters' diff --git a/src/Common/Tooltip/Tooltip.tsx b/src/Common/Tooltip/Tooltip.tsx index 668342f04..59ad4f6c6 100644 --- a/src/Common/Tooltip/Tooltip.tsx +++ b/src/Common/Tooltip/Tooltip.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ -import { cloneElement, useState } from 'react' +import { cloneElement } from 'react' import TippyJS from '@tippyjs/react' -import { SUB_PIXEL_ERROR } from './constants' +import { useIsTextTruncated } from '@Common/Hooks' + import ShortcutKeyComboTooltipContent from './ShortcutKeyComboTooltipContent' import { TooltipProps } from './types' @@ -32,19 +33,7 @@ const Tooltip = ({ children: child, ...rest }: TooltipProps) => { - const [isTextTruncated, setIsTextTruncated] = useState(false) - - const handleMouseEnterEvent: React.MouseEventHandler = (event) => { - const { currentTarget: node } = event - const isTextOverflowing = - node.scrollWidth > node.clientWidth + SUB_PIXEL_ERROR || - node.scrollHeight > node.clientHeight + SUB_PIXEL_ERROR - if (isTextOverflowing && !isTextTruncated) { - setIsTextTruncated(true) - } else if (!isTextOverflowing && isTextTruncated) { - setIsTextTruncated(false) - } - } + const { isTextTruncated, handleMouseEnterEvent } = useIsTextTruncated() const showTooltipWhenShortcutKeyComboProvided = !!shortcutKeyCombo && (alwaysShowTippyOnHover === undefined || alwaysShowTippyOnHover) diff --git a/src/Common/Tooltip/constants.tsx b/src/Common/Tooltip/constants.tsx index 7565be91d..22550f81a 100644 --- a/src/Common/Tooltip/constants.tsx +++ b/src/Common/Tooltip/constants.tsx @@ -17,5 +17,3 @@ export const TOOLTIP_CONTENTS = { INVALID_INPUT: 'Valid input is required for all mandatory fields.', } - -export const SUB_PIXEL_ERROR = 1 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 { 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 ( - + ) + } + + return ( +
+ {nodes.map((node) => { + const isSelected = selectedId === node.id + + const dividerPrefix = + depth > 1 && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: depth - 1 }).map((_, index) => ) + + const content = ( + + ) + + if (node.type === 'heading') { + const isExpanded = expandedMap[node.id] ?? false + + return ( +
+
+
+ {dividerPrefix} + +
+ + + {node.trailingItem && ( +
+ +
+ )} +
+
+
+ + + {isExpanded && ( + + {!node.items?.length ? ( + <> + {dividerPrefix} + + + {node.noItemsText || DEFAULT_NO_ITEMS_TEXT} + + + ) : ( +
+ {node.items.map((nodeItem) => ( + + ))} +
+ )} +
+ )} +
+
+ ) + } + + const itemDivider = + depth > 0 ? ( + + + + ) : null + + return ( +
+ {dividerPrefix} + +
+ {renderNodeItemAction(node, itemDivider, 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..15f9e045d --- /dev/null +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -0,0 +1,40 @@ +.tree-view { + &__container { + .icon-with-divider { + grid-template-rows: 24px auto; + } + + @mixin tree-view-divider { + .tree-view__divider { + background-color: var(--B500); + height: 16px; + border-radius: 3px; + } + } + + &--item { + &:hover { + @include tree-view-divider; + } + } + + [aria-selected="true"] { + @include tree-view-divider; + + .tree-view__container--title { + 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..544d3ec10 --- /dev/null +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -0,0 +1,96 @@ +import { ConditionalWrap } from '@Common/Helper' +import { useIsTextTruncated } from '@Common/Hooks' +import { Tooltip, TooltipProps } 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, + isSelected, +}: 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/constants.ts b/src/Shared/Components/TreeView/constants.ts new file mode 100644 index 000000000..dfa71357c --- /dev/null +++ b/src/Shared/Components/TreeView/constants.ts @@ -0,0 +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/index.ts b/src/Shared/Components/TreeView/index.ts new file mode 100644 index 000000000..89897deba --- /dev/null +++ b/src/Shared/Components/TreeView/index.ts @@ -0,0 +1,2 @@ +export { default as TreeView } from './TreeView.component' +export * from './types' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts new file mode 100644 index 000000000..8266cf458 --- /dev/null +++ b/src/Shared/Components/TreeView/types.ts @@ -0,0 +1,228 @@ +import { SyntheticEvent } from 'react' + +import { TooltipProps } from '@Common/Tooltip' +import { DataAttributes, Never } from '@Shared/types' + +import { IconsProps } from '../Icon' +import { TrailingItemProps } from '../TrailingItem' + +// eslint-disable-next-line no-use-before-define +export type TreeNode = TreeHeading | TreeItem + +/** + * Represents a base node structure for a tree view component. + * + * @typeParam DataAttributeType - The type for data attributes, defaults to null. If it extends `DataAttributes`, the node can have `dataAttributes` of this type. + * + * @property id - Unique identifier for the node. + * @property title - The main title text displayed for the node. + * @property subtitle - Optional subtitle text for the node. + * @property customTooltipConfig - Optional configuration for a custom tooltip. + * @property strikeThrough - If true, the title will be rendered with a line-through style. + * @property startIconConfig - Optional configuration for a start icon, which can be either a standard icon (with `name` and `color`) or a custom JSX element. + * @property trailingItem - Optional configuration for a trailing item (e.g., button, icon) displayed at the end of the node. + * @property dataAttributes - Optional data attributes, present only if `DataAttributeType` extends `DataAttributes`. + */ +type BaseNode = { + /** + * id - Unique identifier for the node. + */ + id: string + /** + * The title of the list item. + */ + title: string + /** + * The subtitle of the list item. + */ + subtitle?: string + /** + * Optional configuration for a custom tooltip. + */ + customTooltipConfig?: TooltipProps + /** + * If true, the title will be rendered with line-through. + */ + strikeThrough?: boolean + /** + * Optional configuration for a start icon, which can be either a standard icon (with `name` and `color`) or a custom JSX element. + */ + startIconConfig?: { + tooltipContent?: string + } & ( + | (Pick & { customIcon?: never }) + | (Never> & { customIcon?: JSX.Element }) + ) + trailingItem?: TrailingItemProps +} & (DataAttributeType extends DataAttributes + ? { + dataAttributes?: DataAttributeType + } + : { + dataAttributes?: never + }) + +export type TreeHeading = BaseNode & { + type: 'heading' + items?: TreeNode[] + /** + * Text to display when there are no items in the list. + * @default DEFAULT_NO_ITEMS_TEXT + */ + noItemsText?: string +} + +export type NodeElementType = HTMLDivElement | HTMLButtonElement | HTMLAnchorElement + +/** + * Represents an item node in a tree structure, supporting different rendering modes. + * + * @template DataAttributeType - The type for custom data attributes, defaults to null. + * + * A `TreeItem` can be rendered as a button, link, or div, each with its own set of properties: + * - When `as` is `'button'` (default), it can have an `onClick` handler. + * - When `as` is `'link'`, it requires an `href`, can have an `onClick` handler, and supports clearing query parameters on navigation. + * - When `as` is `'div'`, it is a non-interactive container. + * + * @property {'item'} type - Identifies the node as an item. + * @property {boolean} [isDisabled=false] - If true, disables the item. + * @property {'button' | 'link' | 'div'} [as] - Determines the rendered element type. + * @property {(e: SyntheticEvent) => void} [onClick] - Callback for click events (button or link only). + * @property {string} [href] - The navigation URL (link only). + * @property {boolean} [clearQueryParamsOnNavigation=false] - If true, clears query parameters during navigation (link only). + */ +export type TreeItem = BaseNode & { + type: 'item' + /** + * @default false + */ + isDisabled?: boolean +} & ( + | { + as?: 'button' + /** + * The callback function to handle click events on the button. + */ + onClick?: (e: SyntheticEvent) => void + href?: never + clearQueryParamsOnNavigation?: never + } + | { + as: 'link' + href: string + /** + * The callback function to handle click events on the nav link. + */ + onClick?: (e: SyntheticEvent) => void + /** + * If `true`, clears query parameters during navigation. + * @default false + */ + clearQueryParamsOnNavigation?: boolean + } + | { + as: 'div' + href?: never + onClick?: never + clearQueryParamsOnNavigation?: never + } + ) + +/** + * Props for the TreeView component. + * + * @template DataAttributeType - The type for data attributes associated with tree nodes. + * + * @property nodes - An array of tree nodes to be rendered in the tree view. + * @property selectedId - (Optional) The ID of the currently selected tree item. + * @property onSelect - (Optional) Callback invoked when a tree item is selected. + * @property mode - (Optional) Determines the navigation mode of the tree view. + * - `'navigation'`: Enables keyboard navigation and focuses only the selected item. + * - `'form'`: Leaves navigation to the browser. + * - @default 'navigation' + * @property variant - (Optional) Visual variant of the tree view. + * - `'primary'`: Uses primary background and hover colors. + * - `'secondary'`: Uses secondary background and hover colors. + * - @default 'primary' + * @property defaultExpandedMap - (Optional) Initial map of node IDs to their expanded state. + * - @default {} + * + * @property depth - (Internal use only) The current depth level in the tree. + * @property getUpdateItemsRefMap - (Internal use only) Function to update the ref map for item buttons/anchors. + * @property flatNodeList - (Internal use only) List of all visible node IDs for keyboard navigation. + * @property onToggle - (Internal use only) Callback invoked when a tree heading is toggled. + * @property expandedMap - (Internal use only) Map of node IDs to their expanded state. + * @property isControlled - (Internal use only) Indicates if the tree view is controlled. + */ +export type TreeViewProps = { + nodes: TreeNode[] + 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. + * @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' + /** + * @default {} + */ + defaultExpandedMap?: Record +} /** + * WARNING: For internal use only. + */ & ( + | { + depth: number + /** + * Would pass this to item button/ref and store it in out ref map through this function. + */ + getUpdateItemsRefMap: (id: string) => (element: NodeElementType) => void + + /** + * 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 + } + | { + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + onToggle?: never + expandedMap?: never + isControlled?: false + } +) + +export interface TreeViewNodeContentProps + extends Pick { + type: 'heading' | 'item' + isSelected: boolean +} + +export interface GetSelectedIdParentNodesProps + extends Pick, 'nodes' | 'selectedId'> {} + +export interface FindSelectedIdParentNodesProps + extends Pick, 'selectedId'> { + node: TreeNode + onFindParentNode: (id: string) => void +} + +export interface GetVisibleNodesProps + extends Pick, 'expandedMap'> { + nodeList: TreeNode[] +} diff --git a/src/Shared/Components/TreeView/utils.ts b/src/Shared/Components/TreeView/utils.ts new file mode 100644 index 000000000..7b64a4b2c --- /dev/null +++ b/src/Shared/Components/TreeView/utils.ts @@ -0,0 +1,101 @@ +import { FindSelectedIdParentNodesProps, GetSelectedIdParentNodesProps, GetVisibleNodesProps } from './types' + +/** + * Recursively traverses a tree structure to find the parent nodes of the node with the specified `selectedId`. + * + * @param node - The current tree node to search within. + * @param onFindParentNode - Callback invoked with the ID of each parent node found on the path to the selected node. + * @returns `true` if the node with `selectedId` is found in the subtree rooted at `node`, otherwise `false`. + * + * @remarks + * - This function is used to collect all parent node IDs leading to a specific node in a tree. + * - The callback `onFindParentNode` is called for each parent node in the path from the root to the selected node. + * - Only nodes of type `'heading'` are considered to have children. + */ +const findSelectedIdParentNodes = ({ + node, + selectedId, + onFindParentNode, +}: FindSelectedIdParentNodesProps): boolean => { + if (node.id === selectedId) { + return true + } + + if (node.type === 'heading' && node.items?.length) { + let found = false + node.items.forEach((childNode) => { + if ( + findSelectedIdParentNodes({ + node: childNode, + onFindParentNode, + selectedId, + }) + ) { + found = true + onFindParentNode(node.id) + } + }) + return found + } + + return false +} + +/** + * Retrieves an array of parent node IDs for the currently selected node. + * + * Iterates through the provided tree nodes and collects the IDs of all parent nodes + * leading to the node identified by `selectedId`. If no node is selected, returns an empty array. + * + * @returns {string[]} An array of parent node IDs for the selected node, or an empty array if no node is selected. + */ +export const getSelectedIdParentNodes = ({ + nodes, + selectedId, +}: GetSelectedIdParentNodesProps): string[] => { + const selectedIdParentNodes: string[] = [] + + if (!selectedId) { + return selectedIdParentNodes + } + + nodes.forEach((node) => { + findSelectedIdParentNodes({ + node, + selectedId, + onFindParentNode: (id: string) => { + selectedIdParentNodes.push(id) + }, + }) + }) + return selectedIdParentNodes +} + +/** + * Recursively traverses a list of tree nodes and returns an array of all node IDs that are present in DOM. + * + * For each node in the provided list: + * - Adds the node's `id` to the result array. + * - If the node is of type `'heading'`, is expanded (as per `expandedMap`), and has child items, + * recursively traverses its child items and includes their IDs as well. + * + * @param nodeList - The list of nodes to traverse. + * @returns An array of strings representing the IDs of all traversed nodes. + */ +export const getVisibleNodes = ({ + nodeList, + expandedMap, +}: GetVisibleNodesProps): 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( + ...getVisibleNodes({ + nodeList: node.items, + expandedMap, + }), + ) + } + return acc + }, []) diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index 268c670b8..15b5d93f0 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -101,6 +101,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'