Add horizontal limit, search bar and change icons of access tree component

This commit is contained in:
carlosmonastyrski
2025-03-26 11:13:12 -03:00
parent bc1b7ddcc5
commit d5741b4a72
9 changed files with 339 additions and 43 deletions

View File

@@ -1,14 +1,11 @@
import { TSecretFolders } from "@app/db/schemas";
import { InternalServerError } from "@app/lib/errors";
export const buildFolderPath = (
folder: TSecretFolders,
foldersMap: Record<string, TSecretFolders>,
depth: number = 0
): string => {
if (depth > 20) {
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
}
if (depth > 20) return;
if (!folder.parentId) {
return depth === 0 ? "/" : "";
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { MongoAbility, MongoQuery } from "@casl/ability";
import {
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faUpLong,
faUpRightAndDownLeftFromCenter,
faWindowRestore
} from "@fortawesome/free-solid-svg-icons";
@@ -10,6 +12,7 @@ import {
Background,
BackgroundVariant,
ConnectionLineType,
ControlButton,
Controls,
Node,
NodeMouseHandler,
@@ -21,8 +24,10 @@ 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 { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
import { BasePermissionEdge } from "./edges";
import { useAccessTree } from "./hooks";
@@ -35,13 +40,29 @@ 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<string>("/");
const accessTreeData = useAccessTree(permissions, selectedPath);
const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData;
const { fitView, getViewport, setCenter } = useReactFlow();
useEffect(() => {
setSelectedPath("/");
}, [environment]);
const { getViewport, setCenter } = 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<Node> = useCallback(
(_, node) => {
@@ -56,13 +77,9 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
useEffect(() => {
setTimeout(() => {
fitView({
padding: 0.2,
duration: 1000,
maxZoom: 1
});
goToRootNode();
}, 1);
}, [fitView, nodes, edges, getViewport()]);
}, [nodes, edges, getViewport()]);
const handleToggleModalView = () =>
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
@@ -133,7 +150,6 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
@@ -151,6 +167,12 @@ 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}
/>
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
@@ -179,7 +201,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
? faDownLeftAndUpRightToCenter
: faUpRightAndDownLeftFromCenter
}
/>
@@ -187,9 +209,25 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
</Tooltip>
</Panel>
)}
{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}
/>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
<Controls position="bottom-left" showFitView={false} showInteractive={false}>
<ControlButton onClick={goToRootNode}>
<Tooltip position="right" content="Go to Root Folder">
<FontAwesomeIcon icon={faUpLong} />
</Tooltip>
</ControlButton>
</Controls>
</ReactFlow>
</div>
</div>

View File

@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
className="mr-1 rounded"
colorSchema="secondary"
onClick={handlePermissionSimulation}
rightIcon={
<FontAwesomeIcon
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
icon={faChevronDown}
/>
}
>
Permission Simulation
</Button>

View File

@@ -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<ProjectPermissionSet, MongoQuery>) => {
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<ProjectPermissionSet, MongoQuery>,
searchPath: string
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]);
@@ -27,10 +44,104 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
currentWorkspace.id
);
const [levelFolderMap, setLevelFolderMap] = useState<LevelFolderMap>({});
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<string, TSecretFolderWithPath[]> = {};
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 { name } = environmentsFolders[environment];
const roleNode = createRoleNode({
subject,
@@ -39,7 +150,13 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
const folderNodes = folders.map((folder) =>
const visibleFolders: TSecretFolderWithPath[] = [];
Object.values(levelFolderMap).forEach((levelData) => {
visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount));
});
const folderNodes = visibleFolders.map((folder) =>
createFolderNode({
folder,
permissions,
@@ -69,10 +186,44 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
});
});
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
const addMoreButtons: Node[] = [];
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
const key = parentId === "null" ? null : parentId;
if (key && levelData.hasMore) {
const showMoreButtonNode = createShowMoreNode({
parentId: key,
onClick: () => showMoreFolders(key),
remaining: levelData.folders.length - levelData.visibleCount
});
addMoreButtons.push(showMoreButtonNode);
folderEdges.push(
createBaseEdge({
source: key,
target: showMoreButtonNode.id,
access: PermissionAccess.Full,
hideEdge: true
})
);
}
});
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 +237,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
secretName,
setSecretName,
viewMode,
setViewMode
setViewMode,
levelFolderMap,
showMoreFolders,
levelsWithMoreFolders,
getLevelCounts,
totalFolderCount
};
};

View File

@@ -0,0 +1,36 @@
import { faChevronRight, faFolderClosed } 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 { createShowMoreNode } from "../utils/createShowMoreNode";
export const ShowMoreButtonNode = ({
data: { onClick, remaining }
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
return (
<>
<Handle type="target" position={Position.Top} style={{ visibility: "hidden" }} />
<Tooltip position="right" content="Click to show 10 more folders">
<button
type="button"
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`}
>
<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>
</Tooltip>
</>
);
};

View File

@@ -7,7 +7,8 @@ export enum PermissionAccess {
export enum PermissionNode {
Role = "role",
Folder = "folder",
Environment = "environment"
Environment = "environment",
ShowMoreButton = "showMoreButton"
}
export enum PermissionEdge {

View File

@@ -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 }
};
};

View File

@@ -0,0 +1,25 @@
import { PermissionNode } from "../types";
export const createShowMoreNode = ({
parentId,
onClick,
remaining
}: {
parentId: string | null;
onClick: () => void;
remaining: number;
}) => {
const id = `show-more-${parentId || "root"}`;
return {
id,
type: PermissionNode.ShowMoreButton,
position: { x: 0, y: 0 },
data: {
parentId,
onClick,
remaining
},
width: 100,
height: 40
};
};

View File

@@ -2,27 +2,60 @@ import Dagre from "@dagrejs/dagre";
import { Edge, Node } from "@xyflow/react";
export const positionElements = (nodes: Node[], edges: Edge[]) => {
const regularNodes = nodes.filter((node) => node.type !== "showMoreButton");
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
const nodeMap: Record<string, Node> = {};
const dagre = new Dagre.graphlib.Graph({ directed: true })
.setDefaultEdgeLabel(() => ({}))
.setGraph({ rankdir: "TB" });
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
nodes.forEach((node) => dagre.setNode(node.id, node));
regularNodes.forEach((node) => dagre.setNode(node.id, node));
Dagre.layout(dagre, {});
return {
nodes: nodes.map((node) => {
const { x, y } = dagre.node(node.id);
const positionedNodes = regularNodes.map((node) => {
const { x, y } = dagre.node(node.id);
return {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
}
};
}),
const positionedNode = {
...node,
position: {
x: x - (node.width ? node.width / 2 : 0),
y: y - (node.height ? node.height / 2 : 0)
}
};
nodeMap[node.id] = positionedNode;
return positionedNode;
});
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 parentWidth = parentNode.width || 150;
const buttonWidth = node.width || 100;
const buttonX = isStart ? parentX - buttonWidth - 20 : parentX + parentWidth + 20;
return {
...node,
position: {
x: buttonX,
y: parentY
}
};
});
return {
nodes: [...positionedNodes, ...positionedShowMoreNodes],
edges
};
};