Search bar improvements and position show more on top of last folder of the row

This commit is contained in:
carlosmonastyrski
2025-03-26 15:19:42 -03:00
parent d5741b4a72
commit 95ccd35f61
5 changed files with 198 additions and 42 deletions

View File

@@ -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<string>("/");
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 && (
<Panel position="top-right" className="flex gap-1.5">
<SecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
{viewMode !== ViewMode.Undocked && (
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
onChange={setSelectedPath}
/>
)}
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
@@ -211,7 +223,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)}
{viewMode === ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<SecretPathInput
<AccessTreeSecretPathInput
placeholder="Provide a path, default is /"
environment={environment}
value={selectedPath}
@@ -221,10 +233,15 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
)}
<PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" showFitView={false} showInteractive={false}>
<Controls position="bottom-left" showInteractive={false} showFitView={false}>
<ControlButton onClick={() => fitView({ duration: 800 })}>
<Tooltip position="right" content="Set view to fit all nodes">
<FontAwesomeIcon icon={faArrowsUpDownLeftRight} />
</Tooltip>
</ControlButton>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to Root Folder">
<FontAwesomeIcon icon={faUpLong} />
<Tooltip position="right" content="Go to root folder">
<FontAwesomeIcon icon={faArrowsToDot} />
</Tooltip>
</ControlButton>
</Controls>

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative">
<div
className={twMerge(
"flex items-center overflow-hidden rounded transition-all duration-300 ease-in-out",
isFocused ? "bg-mineshaft-800 shadow-md" : "bg-mineshaft-700",
isExpanded ? "w-64" : "h-10 w-10"
)}
>
{isExpanded ? (
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faTimes} />
</div>
) : (
<Tooltip position="bottom" content="Search paths">
<div
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
onClick={toggleSearch}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleSearch();
}
}}
>
<FontAwesomeIcon icon={faSearch} />
</div>
</Tooltip>
)}
<div
ref={inputRef}
className={twMerge(
"flex-1 transition-opacity duration-300",
isExpanded ? "opacity-100" : "hidden"
)}
onFocus={handleFocus}
onBlur={handleBlur}
role="search"
>
<div className="custom-input-wrapper">
<SecretPathInput
placeholder={placeholder}
environment={environment}
value={value}
onChange={onChange}
/>
</div>
</div>
</div>
</div>
);
};

View File

@@ -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<typeof createShowMoreNode>["data"] }) => {
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
return (
<>
<Handle type="target" position={Position.Top} style={{ visibility: "hidden" }} />
<Tooltip position="right" content="Click to show 10 more folders">
<button
type="button"
<Tooltip position="right" content={tooltipText}>
<Button
colorSchema="secondary"
variant="plain"
size="xs"
onClick={onClick}
className="group relative flex items-center justify-between gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-800/90 px-4 py-3 text-sm font-medium text-mineshaft-200 shadow-sm transition-all duration-200 hover:border-primary/50 hover:bg-mineshaft-700 hover:text-white hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/30"
aria-label={`Show ${remaining} more folders`}
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolderClosed} className="h-3.5 w-3.5 text-primary/70" />
<span>
{remaining} hidden folder{remaining !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center justify-center rounded-full bg-mineshaft-700/80 p-1 group-hover:bg-primary/20">
<FontAwesomeIcon icon={faChevronRight} className="h-3 w-3" />
</div>
</button>
Show More
</Button>
</Tooltip>
</>
);

View File

@@ -6,6 +6,14 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => {
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
const nodeMap: Record<string, Node> = {};
const childrenMap: Record<string, string[]> = {};
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

View File

@@ -141,7 +141,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)"
}}
>
<div className="h-full w-full flex-col items-center justify-center rounded-md text-white">
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => (
<div
tabIndex={0}