From 95ccd35f61ebc10f912e6628689ad69d57ee8ad6 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 26 Mar 2025 15:19:42 -0300 Subject: [PATCH] Search bar improvements and position show more on top of last folder of the row --- .../permissions/AccessTree/AccessTree.tsx | 51 +++++--- .../components/AccessTreeSecretPathInput.tsx | 114 ++++++++++++++++++ .../AccessTree/nodes/ShowMoreButtonNode.tsx | 29 ++--- .../AccessTree/utils/positionElements.ts | 44 +++++-- .../v2/SecretPathInput/SecretPathInput.tsx | 2 +- 5 files changed, 198 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx index 30705570c..8376ae72f 100644 --- a/frontend/src/components/permissions/AccessTree/AccessTree.tsx +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from "react"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { + faArrowsToDot, + faArrowsUpDownLeftRight, faArrowUpRightFromSquare, faDownLeftAndUpRightToCenter, - faUpLong, faUpRightAndDownLeftFromCenter, faWindowRestore } from "@fortawesome/free-solid-svg-icons"; @@ -24,9 +25,9 @@ import { import { twMerge } from "tailwind-merge"; import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; -import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput"; import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode"; import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; import { BasePermissionEdge } from "./edges"; @@ -46,12 +47,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { const [selectedPath, setSelectedPath] = useState("/"); const accessTreeData = useAccessTree(permissions, selectedPath); const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData; + const [initialRender, setInitialRender] = useState(true); useEffect(() => { setSelectedPath("/"); }, [environment]); - const { getViewport, setCenter } = useReactFlow(); + const { getViewport, setCenter, fitView } = useReactFlow(); const goToRootNode = useCallback(() => { const roleNode = nodes.find((node) => node.type === "role"); @@ -76,10 +78,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { ); useEffect(() => { - setTimeout(() => { - goToRootNode(); - }, 1); - }, [nodes, edges, getViewport()]); + setInitialRender(true); + }, [selectedPath, environment]); + + useEffect(() => { + if (initialRender) { + setTimeout(() => { + goToRootNode(); + setInitialRender(false); + }, 500); + } + }, [nodes, edges, getViewport(), initialRender]); const handleToggleModalView = () => setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); @@ -156,6 +165,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { edgesReconnectable={false} nodesConnectable={false} connectionLineType={ConnectionLineType.SmoothStep} + minZoom={0.001} proOptions={{ hideAttribution: false // we need pro license if we want to hide }} @@ -167,12 +177,14 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => { )} {viewMode !== ViewMode.Docked && ( - + {viewMode !== ViewMode.Undocked && ( + + )} { )} {viewMode === ViewMode.Docked && ( - { )} - + + fitView({ duration: 800 })}> + + + + - - + + diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx new file mode 100644 index 000000000..0e3c8777e --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/AccessTreeSecretPathInput.tsx @@ -0,0 +1,114 @@ +import { useRef, useState } from "react"; +import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; + +type AccessTreeSecretPathInputProps = { + placeholder: string; + environment: string; + value: string; + onChange: (path: string) => 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 = () => { + setTimeout(() => { + setIsFocused(false); + }, 200); + }; + + const focusInput = () => { + const inputElement = inputRef.current?.querySelector("input"); + if (inputElement) { + inputElement.focus(); + } + }; + + const toggleSearch = () => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + setTimeout(focusInput, 300); + } + }; + + 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/ShowMoreButtonNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx index 02b559bf2..2a6cabf43 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/ShowMoreButtonNode.tsx @@ -1,35 +1,30 @@ -import { faChevronRight, faFolderClosed } from "@fortawesome/free-solid-svg-icons"; +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; -import { Tooltip } from "@app/components/v2"; +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 ( <> - - + Show More + ); diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts index 8fd7bbbf5..8b90a752f 100644 --- a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -6,6 +6,14 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => { const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton"); 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(() => ({})) @@ -33,27 +41,49 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => { return positionedNode; }); + const findLastChildNode = (parentId: string): Node | undefined => { + const childrenIds = childrenMap[parentId] || []; + if (childrenIds.length === 0) return undefined; + + const childNodes = childrenIds.map((id) => nodeMap[id]).filter(Boolean); + + if (childNodes.length === 0) return undefined; + + childNodes.sort((a, b) => { + if (a.position.y === b.position.y) { + return b.position.x - a.position.x; + } + return b.position.y - a.position.y; + }); + + return childNodes[0]; + }; + const positionedShowMoreNodes = showMoreNodes.map((node) => { const parentId = node.data.parentId as string; - const { isStart } = node.data; const parentNode = nodeMap[parentId] || positionedNodes[0]; - const parentX = parentNode.position.x; - const parentY = parentNode.position.y; + const lastChildNode = findLastChildNode(parentId); - const parentWidth = parentNode.width || 150; - const buttonWidth = node.width || 100; + const referenceNode = lastChildNode || parentNode; - const buttonX = isStart ? parentX - buttonWidth - 20 : parentX + parentWidth + 20; + const referenceX = referenceNode.position.x; + const referenceY = referenceNode.position.y; + + const referenceWidth = referenceNode.width || 150; + + const buttonX = referenceX + referenceWidth - 85; + const buttonY = referenceY - 25; return { ...node, position: { x: buttonX, - y: parentY + y: buttonY } }; }); + return { nodes: [...positionedNodes, ...positionedShowMoreNodes], edges diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 8e82c2915..31d0ff72c 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -141,7 +141,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
+
{suggestions.map((suggestion, i) => (