diff --git a/ui/app/(prowler)/demo-tree-view/page.tsx b/ui/app/(prowler)/demo-tree-view/page.tsx new file mode 100644 index 0000000000..353aeccc57 --- /dev/null +++ b/ui/app/(prowler)/demo-tree-view/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; +import { notFound } from "next/navigation"; +import { useState } from "react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { TreeView } from "@/components/shadcn/tree-view"; +import { TreeDataItem } from "@/types/tree"; + +/** + * Demo page for the TreeView component. + * + * ⚠️ DEVELOPMENT ONLY - This page is for component demonstration and testing. + * It returns 404 in production environments. + * + * Showcases: + * 1. TreeView with checkboxes and hierarchical selection + * 2. TreeView without checkboxes (navigation mode) + * 3. Custom rendering with renderItem prop + */ + +// Hide in production - evaluated at build time +const IS_DEV = process.env.NODE_ENV === "development"; + +const accountsTreeData: TreeDataItem[] = [ + { + id: "org-1", + name: "Organization Root", + icon: ServerIcon, + children: [ + { + id: "ou-1", + name: "ou-996789098 (Production)", + icon: FolderIcon, + status: "success", + children: [ + { + id: "acc-1", + name: "123456789098", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-2", + name: "123456789099", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-3", + name: "123456789100", + icon: CloudIcon, + status: "success", + }, + ], + }, + { + id: "ou-2", + name: "ou-996789099 (Development)", + icon: FolderIcon, + status: "error", + children: [ + { + id: "acc-4", + name: "223456789098", + icon: CloudIcon, + status: "success", + }, + { + id: "acc-5", + name: "223456789099", + icon: CloudIcon, + status: "error", + }, + ], + }, + { + id: "ou-3", + name: "ou-996789100 (Staging)", + icon: FolderIcon, + isLoading: true, + children: [{ id: "acc-6", name: "323456789098", icon: CloudIcon }], + }, + ], + }, +]; + +export default function DemoTreeViewPage() { + // Return 404 in production - this page is for development only + if (!IS_DEV) { + notFound(); + } + + const [selectedIds, setSelectedIds] = useState([]); + const [expandedIds, setExpandedIds] = useState(["org-1"]); + + return ( +
+

TreeView Component Demo

+ + {/* TreeView with Checkboxes */} +
+
+

+ TreeView with Checkboxes (Account Selector) +

+

+ Select accounts hierarchically. Selecting a parent selects all + children. +

+
+ +
+
+ ( +
+ {item.icon && } + + + {item.name} + + {item.name} + + {hasChildren && !isLeaf && ( + + {item.children?.length} + + )} +
+ )} + /> +
+ +
+

Selected IDs:

+
+              {JSON.stringify(selectedIds, null, 2)}
+            
+
+
+
+ + {/* TreeView without Checkboxes */} +
+
+

+ TreeView without Checkboxes (Navigation) +

+

+ Click to expand/collapse. Use arrow keys to navigate. +

+
+ +
+ +
+
+
+ ); +} diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx index 43da49eb3e..930b70a236 100644 --- a/ui/components/shadcn/checkbox/checkbox.tsx +++ b/ui/components/shadcn/checkbox/checkbox.tsx @@ -1,24 +1,54 @@ "use client"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, MinusIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +const SIZE_STYLES = { + default: { + root: "size-6", + icon: "size-4", + }, + sm: { + root: "size-5", + icon: "size-3.5", + }, +} as const; + +type CheckboxSize = keyof typeof SIZE_STYLES; + +interface CheckboxProps + extends React.ComponentProps { + /** Size variant: "default" (24px) or "sm" (20px) */ + size?: CheckboxSize; + /** Show indeterminate state (minus icon) - used for partial selection in trees */ + indeterminate?: boolean; +} + function Checkbox({ className, + size = "default", + indeterminate, + checked, ...props -}: React.ComponentProps) { +}: CheckboxProps) { + const sizeStyles = SIZE_STYLES[size]; + return ( - + {indeterminate ? ( + + ) : ( + + )} ); } export { Checkbox }; +export type { CheckboxProps, CheckboxSize }; diff --git a/ui/components/shadcn/checkbox/index.ts b/ui/components/shadcn/checkbox/index.ts new file mode 100644 index 0000000000..bbf3fc318c --- /dev/null +++ b/ui/components/shadcn/checkbox/index.ts @@ -0,0 +1,2 @@ +export type { CheckboxProps, CheckboxSize } from "./checkbox"; +export { Checkbox } from "./checkbox"; diff --git a/ui/components/shadcn/tree-view/index.ts b/ui/components/shadcn/tree-view/index.ts new file mode 100644 index 0000000000..6483d3d611 --- /dev/null +++ b/ui/components/shadcn/tree-view/index.ts @@ -0,0 +1,13 @@ +export { TreeItemLabel } from "./tree-item-label"; +export { TreeLeaf } from "./tree-leaf"; +export { TreeNode } from "./tree-node"; +export { TreeSpinner } from "./tree-spinner"; +export { TreeStatusIcon } from "./tree-status-icon"; +export { TreeView } from "./tree-view"; +export { + getAllDescendantIds, + getTreeLeafPadding, + getTreeNodePadding, + TREE_INDENT_REM, + TREE_LEAF_EXTRA_PADDING_REM, +} from "./utils"; diff --git a/ui/components/shadcn/tree-view/tree-item-label.tsx b/ui/components/shadcn/tree-view/tree-item-label.tsx new file mode 100644 index 0000000000..8a40484c14 --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-item-label.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { TreeDataItem } from "@/types/tree"; + +interface TreeItemLabelProps { + /** The tree item to display */ + item: TreeDataItem; + /** Optional text size class (defaults to text-base) */ + textClassName?: string; +} + +/** + * TreeItemLabel component - displays an item's icon and name with truncation. + * + * Features: + * - Optional icon rendering + * - Text truncation with tooltip on hover + * - Consistent layout across tree nodes and leaves + * + * This component extracts the common pattern used in TreeNode and TreeLeaf + * for displaying item content with overflow handling. + */ +export function TreeItemLabel({ item, textClassName }: TreeItemLabelProps) { + return ( +
+ {item.icon && } + + + + {item.name} + + + {item.name} + +
+ ); +} diff --git a/ui/components/shadcn/tree-view/tree-leaf.tsx b/ui/components/shadcn/tree-view/tree-leaf.tsx new file mode 100644 index 0000000000..a9cb6c815b --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-leaf.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { KeyboardEvent } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox"; +import { cn } from "@/lib/utils"; +import { TreeLeafProps } from "@/types/tree"; + +import { TreeItemLabel } from "./tree-item-label"; +import { TreeSpinner } from "./tree-spinner"; +import { TreeStatusIcon } from "./tree-status-icon"; +import { getTreeLeafPadding } from "./utils"; + +/** + * TreeLeaf component for rendering leaf nodes (nodes without children). + * + * Features: + * - Selection via checkbox or click + * - Loading spinner state + * - Custom rendering via renderItem prop + * - Indentation based on nesting level + * - Keyboard navigation support (Enter/Space to select) + */ +export function TreeLeaf({ + item, + level, + selectedIds, + onSelectionChange, + showCheckboxes, + renderItem, +}: TreeLeafProps) { + const isSelected = selectedIds.includes(item.id); + + const handleSelect = () => { + if (!item.disabled) { + onSelectionChange(item.id, item); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleSelect(); + } + }; + + return ( +
+ {item.isLoading && } + {!item.isLoading && item.status && ( + + )} + + {showCheckboxes && ( + e.stopPropagation()} + /> + )} + + {renderItem ? ( + renderItem({ + item, + level, + isLeaf: true, + isSelected, + hasChildren: false, + }) + ) : ( + + )} +
+ ); +} diff --git a/ui/components/shadcn/tree-view/tree-node.tsx b/ui/components/shadcn/tree-view/tree-node.tsx new file mode 100644 index 0000000000..6c2a11e63d --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-node.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronRightIcon } from "lucide-react"; +import { KeyboardEvent } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox"; +import { cn } from "@/lib/utils"; +import { TreeNodeProps } from "@/types/tree"; + +import { TreeItemLabel } from "./tree-item-label"; +import { TreeLeaf } from "./tree-leaf"; +import { TreeSpinner } from "./tree-spinner"; +import { TreeStatusIcon } from "./tree-status-icon"; +import { getAllDescendantIds, getTreeNodePadding } from "./utils"; + +/** + * TreeNode component for rendering expandable nodes with children. + * + * Features: + * - Collapsible content using Radix UI + * - Indeterminate checkbox state when partially selected + * - Recursive selection of all descendants + * - Loading spinner state + * - Custom rendering via renderItem prop + * - Keyboard navigation support (Enter/Space to select, Arrow keys to expand) + */ +export function TreeNode({ + item, + level, + selectedIds, + expandedIds, + onSelectionChange, + onExpandedChange, + showCheckboxes, + renderItem, + expandAll, + enableSelectChildren, +}: TreeNodeProps) { + const isExpanded = expandAll || expandedIds.includes(item.id); + const isSelected = selectedIds.includes(item.id); + + // Calculate indeterminate state based on descendant selection + const descendantIds = getAllDescendantIds(item); + const selectedDescendantCount = descendantIds.filter((id) => + selectedIds.includes(id), + ).length; + const isIndeterminate = + selectedDescendantCount > 0 && + selectedDescendantCount < descendantIds.length; + + const handleToggleExpand = () => { + const newExpandedIds = isExpanded + ? expandedIds.filter((id) => id !== item.id) + : [...expandedIds, item.id]; + onExpandedChange(newExpandedIds); + }; + + const handleSelect = () => { + onSelectionChange(item.id, item); + }; + + const handleContentClick = showCheckboxes ? handleSelect : handleToggleExpand; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "Enter": + case " ": + event.preventDefault(); + handleContentClick(); + break; + case "ArrowRight": + if (!isExpanded) { + event.preventDefault(); + handleToggleExpand(); + } + break; + case "ArrowLeft": + if (isExpanded) { + event.preventDefault(); + handleToggleExpand(); + } + break; + } + }; + + return ( +
+
+ + + {!item.isLoading && item.status && ( + + )} + + {showCheckboxes && ( + e.stopPropagation()} + /> + )} + +
+ {renderItem ? ( + renderItem({ + item, + level, + isLeaf: false, + isSelected, + isExpanded, + isIndeterminate, + hasChildren: true, + }) + ) : ( + + )} +
+
+ + + {isExpanded && ( + + {item.children?.map((child) => ( +
  • + {child.children ? ( + + ) : ( + + )} +
  • + ))} +
    + )} +
    +
    + ); +} diff --git a/ui/components/shadcn/tree-view/tree-spinner.tsx b/ui/components/shadcn/tree-view/tree-spinner.tsx new file mode 100644 index 0000000000..6527cb2ccb --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-spinner.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface TreeSpinnerProps { + className?: string; +} + +/** + * TreeSpinner component - a circular loading indicator for tree nodes. + * + * Features: + * - 20x20 (size-5) default size to match checkbox sm + * - 2.5px stroke for good visibility + * - Uses button-primary color + * - Smooth rotation animation + */ +export function TreeSpinner({ className }: TreeSpinnerProps) { + return ( + + {/* Background track */} + + {/* Animated arc */} + + + ); +} diff --git a/ui/components/shadcn/tree-view/tree-status-icon.tsx b/ui/components/shadcn/tree-view/tree-status-icon.tsx new file mode 100644 index 0000000000..1ef34024b1 --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-status-icon.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CircleCheckIcon, CircleXIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { TREE_ITEM_STATUS, TreeItemStatus } from "@/types/tree"; + +interface TreeStatusIconProps { + status: TreeItemStatus; + className?: string; +} + +/** + * TreeStatusIcon component - displays success or error status for tree nodes. + * + * Features: + * - CircleCheck icon for success (green) + * - CircleX icon for error (red) + * - Same size as TreeSpinner for consistent layout + */ +export function TreeStatusIcon({ status, className }: TreeStatusIconProps) { + if (status === TREE_ITEM_STATUS.SUCCESS) { + return ( + + ); + } + + if (status === TREE_ITEM_STATUS.ERROR) { + return ( + + ); + } + + return null; +} diff --git a/ui/components/shadcn/tree-view/tree-view.tsx b/ui/components/shadcn/tree-view/tree-view.tsx new file mode 100644 index 0000000000..940574677d --- /dev/null +++ b/ui/components/shadcn/tree-view/tree-view.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; + +import { cn } from "@/lib/utils"; +import { TreeDataItem, TreeViewProps } from "@/types/tree"; + +import { TreeLeaf } from "./tree-leaf"; +import { TreeNode } from "./tree-node"; +import { getAllDescendantIds } from "./utils"; + +/** + * TreeView component for rendering hierarchical data structures. + * + * Features: + * - Recursive nested structure support + * - Controlled or uncontrolled selection state + * - Controlled or uncontrolled expansion state + * - Auto-select children when parent is selected (optional) + * - Indeterminate checkbox state for partial selection + * - Loading states with spinners + * - Custom rendering via renderItem prop + * + * @example + * ```tsx + * const data: TreeDataItem[] = [ + * { + * id: "org-1", + * name: "Organization", + * children: [ + * { id: "acc-1", name: "Account 1" }, + * { id: "acc-2", name: "Account 2" }, + * ], + * }, + * ]; + * + * + * ``` + */ +export function TreeView({ + data, + className, + selectedIds: controlledSelectedIds, + onSelectionChange, + expandedIds: controlledExpandedIds, + onExpandedChange, + expandAll = false, + showCheckboxes = false, + enableSelectChildren = true, + renderItem, +}: TreeViewProps) { + const [internalSelectedIds, setInternalSelectedIds] = useState([]); + const [internalExpandedIds, setInternalExpandedIds] = useState([]); + + const selectedIds = controlledSelectedIds ?? internalSelectedIds; + const expandedIds = controlledExpandedIds ?? internalExpandedIds; + + const handleSelectionChange = (itemId: string, item: TreeDataItem) => { + const isSelected = selectedIds.includes(itemId); + let newSelectedIds: string[]; + + if (enableSelectChildren && item.children) { + // When selecting a parent, also select/deselect all descendants + const allItemIds = getAllDescendantIds(item, true); + if (isSelected) { + // Deselect this item and all descendants + newSelectedIds = selectedIds.filter((id) => !allItemIds.includes(id)); + } else { + // Select this item and all descendants + newSelectedIds = Array.from(new Set([...selectedIds, ...allItemIds])); + } + } else { + // Simple toggle without affecting children + newSelectedIds = isSelected + ? selectedIds.filter((id) => id !== itemId) + : [...selectedIds, itemId]; + } + + if (onSelectionChange) { + onSelectionChange(newSelectedIds); + } else { + setInternalSelectedIds(newSelectedIds); + } + }; + + const handleExpandedChange = (newExpandedIds: string[]) => { + if (onExpandedChange) { + onExpandedChange(newExpandedIds); + } else { + setInternalExpandedIds(newExpandedIds); + } + }; + + const items = Array.isArray(data) ? data : [data]; + + return ( +
    +
      + {items.map((item) => ( +
    • + {item.children ? ( + + ) : ( + + )} +
    • + ))} +
    +
    + ); +} diff --git a/ui/components/shadcn/tree-view/utils.ts b/ui/components/shadcn/tree-view/utils.ts new file mode 100644 index 0000000000..1df696518d --- /dev/null +++ b/ui/components/shadcn/tree-view/utils.ts @@ -0,0 +1,50 @@ +import { TreeDataItem } from "@/types/tree"; + +/** + * Tree indentation constants (in rem units). + * Used to calculate consistent padding for nested tree items. + */ +export const TREE_INDENT_REM = 1.25; +export const TREE_LEAF_EXTRA_PADDING_REM = 1.5; + +/** + * Calculates the left padding for a tree node based on its nesting level. + */ +export function getTreeNodePadding(level: number): string { + return `${level * TREE_INDENT_REM}rem`; +} + +/** + * Calculates the left padding for a tree leaf based on its nesting level. + * Leaves have extra padding to align with node content (accounting for expand button). + */ +export function getTreeLeafPadding(level: number): string { + return `${level * TREE_INDENT_REM + TREE_LEAF_EXTRA_PADDING_REM}rem`; +} + +/** + * Recursively collects all descendant IDs from a tree item. + * + * @param item - The tree item to collect IDs from + * @param includeParent - Whether to include the parent item's ID (default: false) + * @returns Array of all descendant IDs (and optionally the parent ID) + * + * @example + * Get only descendant IDs + * const childIds = getAllDescendantIds(parentItem); + * + * Get parent + all descendant IDs + * const allIds = getAllDescendantIds(parentItem, true); + */ +export function getAllDescendantIds( + item: TreeDataItem, + includeParent = false, +): string[] { + const ids = includeParent ? [item.id] : []; + if (item.children) { + for (const child of item.children) { + ids.push(child.id, ...getAllDescendantIds(child, false)); + } + } + return ids; +} diff --git a/ui/types/index.ts b/ui/types/index.ts index 312d9c6e29..badcd6e7f9 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -6,3 +6,4 @@ export * from "./processors"; export * from "./providers"; export * from "./resources"; export * from "./scans"; +export * from "./tree"; diff --git a/ui/types/tree.ts b/ui/types/tree.ts new file mode 100644 index 0000000000..ff18bf77c5 --- /dev/null +++ b/ui/types/tree.ts @@ -0,0 +1,114 @@ +/** + * Tree View Component Types + * + * Types for the TreeView component used to render hierarchical data structures + * with support for selection, expansion, and custom rendering. + */ + +/** + * Status indicator for tree items after loading completes + */ +export const TREE_ITEM_STATUS = { + SUCCESS: "success", + ERROR: "error", +} as const; + +export type TreeItemStatus = + (typeof TREE_ITEM_STATUS)[keyof typeof TREE_ITEM_STATUS]; + +/** + * Represents a single item in the tree structure. + * Items can have nested children to create a hierarchical tree. + */ +export interface TreeDataItem { + /** Unique identifier for the tree item */ + id: string; + /** Display name for the item */ + name: string; + /** Optional icon component to render alongside the name */ + icon?: React.ComponentType<{ className?: string }>; + /** Child items (if present, this node is expandable) */ + children?: TreeDataItem[]; + /** Whether the item is disabled (cannot be selected) */ + disabled?: boolean; + /** Whether the item is in a loading state (shows spinner) */ + isLoading?: boolean; + /** Status indicator shown after loading (success/error) */ + status?: TreeItemStatus; + /** Additional CSS classes for the item */ + className?: string; +} + +/** + * Props for the main TreeView component + */ +export interface TreeViewProps { + /** Tree data - can be a single root or array of roots */ + data: TreeDataItem[] | TreeDataItem; + /** Additional CSS classes for the root container */ + className?: string; + /** Controlled selected item IDs */ + selectedIds?: string[]; + /** Callback when selection changes */ + onSelectionChange?: (selectedIds: string[]) => void; + /** Controlled expanded item IDs */ + expandedIds?: string[]; + /** Callback when expansion state changes */ + onExpandedChange?: (expandedIds: string[]) => void; + /** Expand all nodes by default */ + expandAll?: boolean; + /** Show checkboxes for selection */ + showCheckboxes?: boolean; + /** Auto-select children when parent is selected */ + enableSelectChildren?: boolean; + /** Custom render function for each item */ + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; +} + +/** + * Parameters passed to the custom renderItem function + */ +export interface TreeRenderItemParams { + /** The tree item being rendered */ + item: TreeDataItem; + /** Nesting depth level (0 = root) */ + level: number; + /** Whether this is a leaf node (no children) */ + isLeaf: boolean; + /** Whether this item is selected */ + isSelected: boolean; + /** Whether this item is expanded (only for non-leaf nodes) */ + isExpanded?: boolean; + /** Whether this item has partial child selection (indeterminate state) */ + isIndeterminate?: boolean; + /** Whether this item has children */ + hasChildren: boolean; +} + +/** + * Internal props for TreeNode component (expandable nodes) + */ +export interface TreeNodeProps { + item: TreeDataItem; + level: number; + selectedIds: string[]; + expandedIds: string[]; + onSelectionChange: (id: string, item: TreeDataItem) => void; + onExpandedChange: (ids: string[]) => void; + showCheckboxes: boolean; + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; + expandAll: boolean; + enableSelectChildren: boolean; +} + +/** + * Internal props for TreeLeaf component (non-expandable nodes) + */ +export interface TreeLeafProps { + item: TreeDataItem; + level: number; + selectedIds: string[]; + onSelectionChange: (id: string, item: TreeDataItem) => void; + showCheckboxes: boolean; + renderItem?: (params: TreeRenderItemParams) => React.ReactNode; +}