diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 97cd197557..842eb2bb7c 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -634,10 +634,29 @@ export const secretFolderServiceFactory = ({ const relevantFolders = folders.filter((folder) => folder.envId === env.id); const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder])); - const foldersWithPath = relevantFolders.map((folder) => ({ - ...folder, - path: buildFolderPath(folder, foldersMap) - })); + const foldersWithPath = relevantFolders + .map((folder) => { + try { + return { + ...folder, + path: buildFolderPath(folder, foldersMap) + }; + } catch (error) { + return null; + } + }) + .filter(Boolean) as { + path: string; + id: string; + createdAt: Date; + updatedAt: Date; + name: string; + envId: string; + version?: number | null | undefined; + parentId?: string | null | undefined; + isReserved?: boolean | undefined; + description?: string | undefined; + }[]; return [env.slug, { ...env, folders: foldersWithPath }]; }) diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 609a39c4b4..76e80d8b6e 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { + faAnglesUp, faArrowUpRightFromSquare, + faDownLeftAndUpRightToCenter, faUpRightAndDownLeftFromCenter, faWindowRestore } from "@fortawesome/free-solid-svg-icons"; @@ -10,6 +12,7 @@ import { Background, BackgroundVariant, ConnectionLineType, + ControlButton, Controls, Node, NodeMouseHandler, @@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge"; import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; -import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; +import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput"; +import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; +import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components"; import { BasePermissionEdge } from "./edges"; import { useAccessTree } from "./hooks"; import { FolderNode, RoleNode } from "./nodes"; @@ -35,13 +40,30 @@ export type AccessTreeProps = { const EdgeTypes = { base: BasePermissionEdge }; -const NodeTypes = { role: RoleNode, folder: FolderNode }; +const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode }; const AccessTreeContent = ({ permissions }: AccessTreeProps) => { - const accessTreeData = useAccessTree(permissions); - const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; + const [selectedPath, setSelectedPath] = useState("/"); + const accessTreeData = useAccessTree(permissions, selectedPath); + const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData; + const [initialRender, setInitialRender] = useState(true); - const { fitView, getViewport, setCenter } = useReactFlow(); + useEffect(() => { + setSelectedPath("/"); + }, [environment]); + + const { getViewport, setCenter, fitView } = useReactFlow(); + + const goToRootNode = useCallback(() => { + const roleNode = nodes.find((node) => node.type === "role"); + if (roleNode) { + setCenter( + roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0), + roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0), + { duration: 800, zoom: 1 } + ); + } + }, [nodes, setCenter]); const onNodeClick: NodeMouseHandler = useCallback( (_, node) => { @@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { ); useEffect(() => { - setTimeout(() => { - fitView({ - padding: 0.2, - duration: 1000, - maxZoom: 1 - }); - }, 1); - }, [fitView, nodes, edges, getViewport()]); + setInitialRender(true); + }, [selectedPath, environment]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (initialRender) { + timer = setTimeout(() => { + goToRootNode(); + setInitialRender(false); + }, 500); + } + return () => clearTimeout(timer); + }, [nodes, edges, getViewport(), initialRender, goToRootNode]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); @@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { edges={edges} edgeTypes={EdgeTypes} nodeTypes={NodeTypes} - fitView onNodeClick={onNodeClick} colorMode="dark" nodesDraggable={false} edgesReconnectable={false} nodesConnectable={false} connectionLineType={ConnectionLineType.SmoothStep} + minZoom={0.001} proOptions={{ hideAttribution: false // we need pro license if we want to hide }} @@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} {viewMode !== ViewMode.Docked && ( + {viewMode !== ViewMode.Undocked && ( + + )} { { @@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} - + {viewMode === ViewMode.Docked && ( + + + + )} - + fitView({ duration: 800 })} + > + + + + + + diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx index 476f2d27c6..6e3790b3c0 100644 --- a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -46,6 +46,12 @@ export const PermissionSimulation = ({ className="mr-1 rounded" colorSchema="secondary" onClick={handlePermissionSimulation} + rightIcon={ + + } > Permission Simulation diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index 5672230c80..a03f8fa0fe 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; +import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; import { useAccessTreeContext } from "../components"; import { PermissionAccess } from "../types"; @@ -15,8 +16,24 @@ import { getSubjectActionRuleMap, positionElements } from "../utils"; +import { createShowMoreNode } from "../utils/createShowMoreNode"; -export const useAccessTree = (permissions: MongoAbility) => { +const INITIAL_FOLDERS_PER_LEVEL = 10; +const FOLDERS_INCREMENT = 10; + +type LevelFolderMap = Record< + string, + { + folders: TSecretFolderWithPath[]; + visibleCount: number; + hasMore: boolean; + } +>; + +export const useAccessTree = ( + permissions: MongoAbility, + searchPath: string +) => { const { currentWorkspace } = useWorkspace(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const [nodes, setNodes] = useNodesState([]); @@ -27,19 +44,124 @@ export const useAccessTree = (permissions: MongoAbility({}); + const [totalFolderCount, setTotalFolderCount] = useState(0); + + const showMoreFolders = (parentId: string) => { + setLevelFolderMap((prevMap) => { + const level = prevMap[parentId]; + if (!level) return prevMap; + + const newVisibleCount = Math.min( + level.visibleCount + FOLDERS_INCREMENT, + level.folders.length + ); + + return { + ...prevMap, + [parentId]: { + ...level, + visibleCount: newVisibleCount, + hasMore: newVisibleCount < level.folders.length + } + }; + }); + }; + + const levelsWithMoreFolders = Object.entries(levelFolderMap) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_, level]) => level.hasMore) + .map(([parentId]) => parentId); + + const getLevelCounts = (parentId: string) => { + const level = levelFolderMap[parentId]; + if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false }; + + return { + visibleCount: level.visibleCount, + totalCount: level.folders.length, + hasMore: level.hasMore + }; + }; + useEffect(() => { if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return; - const { folders, name } = environmentsFolders[environment]; + const { folders } = environmentsFolders[environment]; + setTotalFolderCount(folders.length); + const groupedFolders: Record = {}; + + const filteredFolders = folders.filter((folder) => { + if (folder.path.startsWith(searchPath)) { + return true; + } + + if ( + searchPath.startsWith(folder.path) && + (folder.path === "/" || + searchPath === folder.path || + searchPath.indexOf("/", folder.path.length) === folder.path.length) + ) { + return true; + } + + return false; + }); + + filteredFolders.forEach((folder) => { + const parentId = folder.parentId || ""; + if (!groupedFolders[parentId]) { + groupedFolders[parentId] = []; + } + groupedFolders[parentId].push(folder); + }); + + const newLevelFolderMap: LevelFolderMap = {}; + + Object.entries(groupedFolders).forEach(([parentId, folderList]) => { + const key = parentId; + newLevelFolderMap[key] = { + folders: folderList, + visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length), + hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL + }; + }); + + setLevelFolderMap(newLevelFolderMap); + }, [permissions, environmentsFolders, environment, subject, secretName, searchPath]); + + useEffect(() => { + if ( + !environmentsFolders || + !permissions || + !environmentsFolders[environment] || + Object.keys(levelFolderMap).length === 0 + ) + return; + + const { slug } = environmentsFolders[environment]; const roleNode = createRoleNode({ subject, - environment: name + environment: slug, + environments: environmentsFolders, + onSubjectChange: setSubject, + onEnvironmentChange: setEnvironment }); const actionRuleMap = getSubjectActionRuleMap(subject, permissions); - const folderNodes = folders.map((folder) => + const visibleFolders: TSecretFolderWithPath[] = []; + Object.entries(levelFolderMap).forEach(([key, levelData]) => { + if (key !== "__rootFolderId") { + visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount)); + } + }); + + // eslint-disable-next-line no-underscore-dangle + const rootFolder = levelFolderMap.__rootFolderId?.folders[0]; + + const folderNodes = visibleFolders.map((folder) => createFolderNode({ folder, permissions, @@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility { - const actions = Object.values(folder.actions); + const folderEdges: Edge[] = []; + if (rootFolder) { + const rootFolderNode = folderNodes.find( + (node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path + ); + + if (rootFolderNode) { + const rootActions = Object.values(rootFolderNode.data.actions); + let rootAccess: PermissionAccess; + + if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) { + rootAccess = PermissionAccess.Full; + } else if ( + Object.values(rootActions).some((action) => action === PermissionAccess.Partial) + ) { + rootAccess = PermissionAccess.Partial; + } else { + rootAccess = PermissionAccess.None; + } + + folderEdges.push( + createBaseEdge({ + source: roleNode.id, + target: rootFolderNode.id, + access: rootAccess + }) + ); + } + } + + folderNodes.forEach(({ data: folder }) => { + if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) { + return; + } + + const actions = Object.values(folder.actions); let access: PermissionAccess; + if (Object.values(actions).some((action) => action === PermissionAccess.Full)) { access = PermissionAccess.Full; } else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) { @@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility { + if (parentId === "__rootFolderId") return; + + const key = parentId === "null" ? null : parentId; + + if (key && levelData.hasMore) { + const showMoreButtonNode = createShowMoreNode({ + parentId: key, + onClick: () => showMoreFolders(key), + remaining: levelData.folders.length - levelData.visibleCount, + subject + }); + + addMoreButtons.push(showMoreButtonNode); + + folderEdges.push( + createBaseEdge({ + source: key, + target: showMoreButtonNode.id, + access: PermissionAccess.Partial + }) + ); + } + }); + + const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]); setNodes(init.nodes); setEdges(init.edges); - }, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); + }, [ + levelFolderMap, + permissions, + environmentsFolders, + environment, + subject, + secretName, + setNodes, + setEdges + ]); return { nodes, @@ -86,6 +281,11 @@ export const useAccessTree = (permissions: MongoAbility void; +}; + +export const AccessTreeSecretPathInput = ({ + placeholder, + environment, + value, + onChange +}: AccessTreeSecretPathInputProps) => { + const [isFocused, setIsFocused] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + const timeout: NodeJS.Timeout = setTimeout(() => { + setIsFocused(false); + }, 200); + return () => clearTimeout(timeout); + }; + + useEffect(() => { + if (!isFocused) { + setIsExpanded(false); + } + }, [isFocused]); + + const focusInput = () => { + const inputElement = inputRef.current?.querySelector("input"); + if (inputElement) { + inputElement.focus(); + } + }; + + const toggleSearch = () => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + const timeout: NodeJS.Timeout = setTimeout(focusInput, 300); + return () => clearTimeout(timeout); + } + return () => {}; + }; + + return ( +
+
+ {isExpanded ? ( +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+ ) : ( + +
{ + if (e.key === "Enter" || e.key === " ") { + toggleSearch(); + } + }} + > + +
+
+ )} + +
+
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx index d38d896f2f..f6ffe212ea 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -1,10 +1,42 @@ +import { Dispatch, SetStateAction } from "react"; +import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; +import { Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { createRoleNode } from "../utils"; +const getSubjectIcon = (subject: ProjectPermissionSub) => { + switch (subject) { + case ProjectPermissionSub.Secrets: + return ; + case ProjectPermissionSub.SecretFolders: + return ; + case ProjectPermissionSub.DynamicSecrets: + return ; + case ProjectPermissionSub.SecretImports: + return ; + default: + return ; + } +}; + +const formatLabel = (text: string) => { + return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +}; + export const RoleNode = ({ - data: { subject, environment } -}: NodeProps & { data: ReturnType["data"] }) => { + data: { subject, environment, onSubjectChange, onEnvironmentChange, environments } +}: NodeProps & { + data: ReturnType["data"] & { + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; + environments: TProjectEnvironmentsFolders; + }; +}) => { return ( <> -
-
- {subject.replace("-", " ")} Access -
-

{environment}

+
+
+
+
Subject
+ +
+ +
+
Environment
+
diff --git a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx new file mode 100644 index 0000000000..dc412d8ca3 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx @@ -0,0 +1,37 @@ +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { Button, Tooltip } from "@app/components/v2"; + +import { createShowMoreNode } from "../utils/createShowMoreNode"; + +export const ShowMoreButtonNode = ({ + data: { onClick, remaining } +}: NodeProps & { data: ReturnType["data"] }) => { + const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`; + + return ( +
+ + +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/types/index.ts b/frontend/src/components/permissions/AccessTree/types/index.ts index 9536d7f86b..c5da349159 100644 --- a/frontend/src/components/permissions/AccessTree/types/index.ts +++ b/frontend/src/components/permissions/AccessTree/types/index.ts @@ -7,7 +7,8 @@ export enum PermissionAccess { export enum PermissionNode { Role = "role", Folder = "folder", - Environment = "environment" + Environment = "environment", + ShowMoreButton = "showMoreButton" } export enum PermissionEdge { diff --git a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts index cc53360c83..0e4aa0c9df 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts @@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types"; export const createBaseEdge = ({ source, target, - access + access, + hideEdge = false }: { source: string; target: string; access: PermissionAccess; + hideEdge?: boolean; }) => { const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; return { @@ -17,10 +19,12 @@ export const createBaseEdge = ({ source, target, type: PermissionEdge.Base, - markerEnd: { - type: MarkerType.ArrowClosed, - color - }, - style: { stroke: color } + markerEnd: hideEdge + ? undefined + : { + type: MarkerType.ArrowClosed, + color + }, + style: { stroke: hideEdge ? "transparent" : color } }; }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts index 354a69482e..69ec608968 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts @@ -1,17 +1,31 @@ +import { Dispatch, SetStateAction } from "react"; + +import { ProjectPermissionSub } from "@app/context"; +import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types"; + import { PermissionNode } from "../types"; export const createRoleNode = ({ subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }: { subject: string; environment: string; + environments: TProjectEnvironmentsFolders; + onSubjectChange: Dispatch>; + onEnvironmentChange: (value: string) => void; }) => ({ id: `role-${subject}-${environment}`, position: { x: 0, y: 0 }, data: { subject, - environment + environment, + environments, + onSubjectChange, + onEnvironmentChange }, type: PermissionNode.Role, height: 48, diff --git a/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts new file mode 100644 index 0000000000..f12cd598e9 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createShowMoreNode.ts @@ -0,0 +1,45 @@ +import { ProjectPermissionSub } from "@app/context"; + +import { PermissionNode } from "../types"; + +export const createShowMoreNode = ({ + parentId, + onClick, + remaining, + subject +}: { + parentId: string | null; + onClick: () => void; + remaining: number; + subject: ProjectPermissionSub; +}) => { + let height: number; + + switch (subject) { + case ProjectPermissionSub.DynamicSecrets: + height = 130; + break; + case ProjectPermissionSub.Secrets: + height = 85; + break; + default: + height = 64; + } + const id = `show-more-${parentId || "root"}`; + return { + id, + type: PermissionNode.ShowMoreButton, + position: { x: 0, y: 0 }, + data: { + parentId, + onClick, + remaining + }, + width: 150, + height, + style: { + background: "transparent", + border: "none" + } + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 523b402d1d..c4daf6317b 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre"; import { Edge, Node } from "@xyflow/react"; export const positionElements = (nodes: Node[], edges: Edge[]) => { + const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton"); + const showMoreParentIds = new Set( + showMoreNodes.map((node) => node.data.parentId).filter(Boolean) + ); + + const nodeMap: Record = {}; + const childrenMap: Record = {}; + + edges.forEach((edge) => { + if (!childrenMap[edge.source]) { + childrenMap[edge.source] = []; + } + childrenMap[edge.source].push(edge.target); + }); + const dagre = new Dagre.graphlib.Graph({ directed: true }) .setDefaultEdgeLabel(() => ({})) - .setGraph({ rankdir: "TB" }); + .setGraph({ + rankdir: "TB", + nodesep: 50, + ranksep: 70 + }); + + nodes.forEach((node) => { + dagre.setNode(node.id, { + width: node.width || 150, + height: node.height || 40 + }); + }); edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); - nodes.forEach((node) => dagre.setNode(node.id, node)); Dagre.layout(dagre, {}); - return { - nodes: nodes.map((node) => { - const { x, y } = dagre.node(node.id); + const positionedNodes = nodes.map((node) => { + const { x, y } = dagre.node(node.id); + if (node.type === "role") { return { ...node, position: { x: x - (node.width ? node.width / 2 : 0), - y: y - (node.height ? node.height / 2 : 0) + y: y - 150 } }; - }), + } + + return { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + }, + style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style + }; + }); + + positionedNodes.forEach((node) => { + nodeMap[node.id] = node; + }); + + Array.from(showMoreParentIds).forEach((parentId) => { + const showMoreNodeIndex = positionedNodes.findIndex( + (node) => node.type === "showMoreButton" && node.data.parentId === parentId + ); + + if (showMoreNodeIndex !== -1) { + const siblings = positionedNodes.filter( + (node) => node.data?.parentId === parentId && node.type !== "showMoreButton" + ); + + if (siblings.length > 0) { + const rightmostSibling = siblings.reduce( + (rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost), + siblings[0] + ); + + positionedNodes[showMoreNodeIndex] = { + ...positionedNodes[showMoreNodeIndex], + position: { + x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30, + y: rightmostSibling.position.y + } + }; + } + } + }); + + return { + nodes: positionedNodes, edges }; }; diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 8e82c2915f..c82e492e8b 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -48,7 +48,6 @@ export const SecretPathInput = ({ }, [propValue]); useEffect(() => { - // update secret path if input is valid if ( (debouncedInputValue.length > 0 && debouncedInputValue[debouncedInputValue.length - 1] === "/") || @@ -59,7 +58,6 @@ export const SecretPathInput = ({ }, [debouncedInputValue]); useEffect(() => { - // filter suggestions based on matching const searchFragment = debouncedInputValue.split("/").pop() || ""; const filteredSuggestions = folders .filter((suggestionEntry) => @@ -78,7 +76,6 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - // removed trailing slash const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; onChange?.(newValue); setInputValue(newValue); @@ -102,7 +99,6 @@ export const SecretPathInput = ({ }; const handleInputChange = (e: any) => { - // propagate event to react-hook-form onChange if (onChange) { onChange(e.target.value); } @@ -141,7 +137,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
+
{suggestions.map((suggestion, i) => (