mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 10:28:22 +00:00
General improvements to Access Tree view
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
import { TSecretFolders } from "@app/db/schemas";
|
import { TSecretFolders } from "@app/db/schemas";
|
||||||
|
import { InternalServerError } from "@app/lib/errors";
|
||||||
|
|
||||||
export const buildFolderPath = (
|
export const buildFolderPath = (
|
||||||
folder: TSecretFolders,
|
folder: TSecretFolders,
|
||||||
foldersMap: Record<string, TSecretFolders>,
|
foldersMap: Record<string, TSecretFolders>,
|
||||||
depth: number = 0
|
depth: number = 0
|
||||||
): string => {
|
): string => {
|
||||||
if (depth > 20) return "";
|
if (depth > 20) {
|
||||||
|
throw new InternalServerError({ message: "Maximum folder depth of 20 exceeded" });
|
||||||
|
}
|
||||||
if (!folder.parentId) {
|
if (!folder.parentId) {
|
||||||
return depth === 0 ? "/" : "";
|
return depth === 0 ? "/" : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,10 +619,29 @@ export const secretFolderServiceFactory = ({
|
|||||||
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
||||||
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
||||||
|
|
||||||
const foldersWithPath = relevantFolders.map((folder) => ({
|
const foldersWithPath = relevantFolders
|
||||||
...folder,
|
.map((folder) => {
|
||||||
path: buildFolderPath(folder, foldersMap)
|
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 }];
|
return [env.slug, { ...env, folders: foldersWithPath }];
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||||
import {
|
import {
|
||||||
faAnglesUp,
|
faAnglesUp,
|
||||||
faArrowsUpDownLeftRight,
|
|
||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
faDownLeftAndUpRightToCenter,
|
faDownLeftAndUpRightToCenter,
|
||||||
faUpRightAndDownLeftFromCenter,
|
faUpRightAndDownLeftFromCenter,
|
||||||
@@ -29,7 +28,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
|||||||
|
|
||||||
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
|
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
|
||||||
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
|
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
|
||||||
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
|
import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components";
|
||||||
import { BasePermissionEdge } from "./edges";
|
import { BasePermissionEdge } from "./edges";
|
||||||
import { useAccessTree } from "./hooks";
|
import { useAccessTree } from "./hooks";
|
||||||
import { FolderNode, RoleNode } from "./nodes";
|
import { FolderNode, RoleNode } from "./nodes";
|
||||||
@@ -231,14 +230,12 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
|||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
<PermissionSimulation {...accessTreeData} />
|
|
||||||
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
|
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
|
||||||
<Controls position="bottom-left" showInteractive={false} showFitView={false}>
|
<Controls
|
||||||
<ControlButton onClick={() => fitView({ duration: 800 })}>
|
position="bottom-left"
|
||||||
<Tooltip position="right" content="Set view to fit all nodes">
|
showInteractive={false}
|
||||||
<FontAwesomeIcon icon={faArrowsUpDownLeftRight} />
|
onFitView={() => fitView({ duration: 800 })}
|
||||||
</Tooltip>
|
>
|
||||||
</ControlButton>
|
|
||||||
<ControlButton onClick={goToRootNode}>
|
<ControlButton onClick={goToRootNode}>
|
||||||
<Tooltip position="right" content="Go to root folder">
|
<Tooltip position="right" content="Go to root folder">
|
||||||
<FontAwesomeIcon icon={faAnglesUp} />
|
<FontAwesomeIcon icon={faAnglesUp} />
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ export const useAccessTree = (
|
|||||||
|
|
||||||
const { folders } = environmentsFolders[environment];
|
const { folders } = environmentsFolders[environment];
|
||||||
setTotalFolderCount(folders.length);
|
setTotalFolderCount(folders.length);
|
||||||
|
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
|
||||||
const searchPathFolder = folders.find((folder) => folder.path === searchPath);
|
|
||||||
|
|
||||||
const filteredFolders = folders.filter((folder) => {
|
const filteredFolders = folders.filter((folder) => {
|
||||||
if (folder.path === searchPath) {
|
if (folder.path.startsWith(searchPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
folder.path.startsWith(searchPath) &&
|
searchPath.startsWith(folder.path) &&
|
||||||
(searchPath === "/" || folder.path.charAt(searchPath.length) === "/")
|
(folder.path === "/" ||
|
||||||
|
searchPath === folder.path ||
|
||||||
|
searchPath.indexOf("/", folder.path.length) === folder.path.length)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -107,17 +108,11 @@ export const useAccessTree = (
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootFolder = searchPathFolder || filteredFolders.find((f) => f.path === "/");
|
|
||||||
|
|
||||||
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
|
|
||||||
|
|
||||||
filteredFolders.forEach((folder) => {
|
filteredFolders.forEach((folder) => {
|
||||||
const parentId = folder.parentId || "";
|
const parentId = folder.parentId || "";
|
||||||
|
|
||||||
if (!groupedFolders[parentId]) {
|
if (!groupedFolders[parentId]) {
|
||||||
groupedFolders[parentId] = [];
|
groupedFolders[parentId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
groupedFolders[parentId].push(folder);
|
groupedFolders[parentId].push(folder);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,18 +127,7 @@ export const useAccessTree = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rootFolder) {
|
setLevelFolderMap(newLevelFolderMap);
|
||||||
setLevelFolderMap({
|
|
||||||
...newLevelFolderMap,
|
|
||||||
__rootFolderId: {
|
|
||||||
folders: [rootFolder],
|
|
||||||
visibleCount: 1,
|
|
||||||
hasMore: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLevelFolderMap(newLevelFolderMap);
|
|
||||||
}
|
|
||||||
}, [permissions, environmentsFolders, environment, subject, secretName, searchPath]);
|
}, [permissions, environmentsFolders, environment, subject, secretName, searchPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,11 +139,14 @@ export const useAccessTree = (
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const { name } = environmentsFolders[environment];
|
const { slug } = environmentsFolders[environment];
|
||||||
|
|
||||||
const roleNode = createRoleNode({
|
const roleNode = createRoleNode({
|
||||||
subject,
|
subject,
|
||||||
environment: name
|
environment: slug,
|
||||||
|
environments: environmentsFolders,
|
||||||
|
onSubjectChange: setSubject,
|
||||||
|
onEnvironmentChange: setEnvironment
|
||||||
});
|
});
|
||||||
|
|
||||||
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
|
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
|
||||||
@@ -252,7 +239,8 @@ export const useAccessTree = (
|
|||||||
const showMoreButtonNode = createShowMoreNode({
|
const showMoreButtonNode = createShowMoreNode({
|
||||||
parentId: key,
|
parentId: key,
|
||||||
onClick: () => showMoreFolders(key),
|
onClick: () => showMoreFolders(key),
|
||||||
remaining: levelData.folders.length - levelData.visibleCount
|
remaining: levelData.folders.length - levelData.visibleCount,
|
||||||
|
subject
|
||||||
});
|
});
|
||||||
|
|
||||||
addMoreButtons.push(showMoreButtonNode);
|
addMoreButtons.push(showMoreButtonNode);
|
||||||
@@ -261,8 +249,7 @@ export const useAccessTree = (
|
|||||||
createBaseEdge({
|
createBaseEdge({
|
||||||
source: key,
|
source: key,
|
||||||
target: showMoreButtonNode.id,
|
target: showMoreButtonNode.id,
|
||||||
access: PermissionAccess.Full,
|
access: PermissionAccess.Partial
|
||||||
hideEdge: true
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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";
|
import { createRoleNode } from "../utils";
|
||||||
|
|
||||||
|
const getSubjectIcon = (subject: ProjectPermissionSub) => {
|
||||||
|
switch (subject) {
|
||||||
|
case ProjectPermissionSub.Secrets:
|
||||||
|
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
|
||||||
|
case ProjectPermissionSub.SecretFolders:
|
||||||
|
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
|
||||||
|
case ProjectPermissionSub.DynamicSecrets:
|
||||||
|
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
|
||||||
|
case ProjectPermissionSub.SecretImports:
|
||||||
|
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
|
||||||
|
default:
|
||||||
|
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLabel = (text: string) => {
|
||||||
|
return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
export const RoleNode = ({
|
export const RoleNode = ({
|
||||||
data: { subject, environment }
|
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
|
||||||
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
|
}: NodeProps & {
|
||||||
|
data: ReturnType<typeof createRoleNode>["data"] & {
|
||||||
|
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||||
|
onEnvironmentChange: (value: string) => void;
|
||||||
|
environments: TProjectEnvironmentsFolders;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -12,11 +44,60 @@ export const RoleNode = ({
|
|||||||
className="pointer-events-none !cursor-pointer opacity-0"
|
className="pointer-events-none !cursor-pointer opacity-0"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
/>
|
/>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
|
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
|
||||||
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
|
<div className="flex w-full min-w-[240px] flex-col gap-4">
|
||||||
<span className="capitalize">{subject.replace("-", " ")} Access</span>
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
|
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
|
||||||
<p className="truncate capitalize">{environment}</p>
|
<Select
|
||||||
|
value={subject}
|
||||||
|
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
|
||||||
|
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
|
||||||
|
position="popper"
|
||||||
|
dropdownContainerClassName="max-w-none"
|
||||||
|
aria-label="Subject"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
ProjectPermissionSub.Secrets,
|
||||||
|
ProjectPermissionSub.SecretFolders,
|
||||||
|
ProjectPermissionSub.DynamicSecrets,
|
||||||
|
ProjectPermissionSub.SecretImports
|
||||||
|
].map((sub) => {
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
|
||||||
|
value={sub}
|
||||||
|
key={sub}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getSubjectIcon(sub)}
|
||||||
|
<span className="font-medium">{formatLabel(sub)}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
|
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
|
||||||
|
<Select
|
||||||
|
value={environment}
|
||||||
|
onValueChange={onEnvironmentChange}
|
||||||
|
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
|
||||||
|
position="popper"
|
||||||
|
dropdownContainerClassName="max-w-none"
|
||||||
|
aria-label="Environment"
|
||||||
|
>
|
||||||
|
{Object.values(environments).map((env) => (
|
||||||
|
<SelectItem
|
||||||
|
key={env.slug}
|
||||||
|
value={env.slug}
|
||||||
|
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
|
||||||
|
>
|
||||||
|
<div className="ml-3 font-medium">{env.name}</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,20 +12,26 @@ export const ShowMoreButtonNode = ({
|
|||||||
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
|
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-full w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-2">
|
||||||
<Handle type="target" position={Position.Top} style={{ visibility: "hidden" }} />
|
<Handle
|
||||||
|
type="target"
|
||||||
|
className="pointer-events-none !cursor-pointer opacity-0"
|
||||||
|
position={Position.Top}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tooltip position="right" content={tooltipText}>
|
<div className="flex items-center justify-center">
|
||||||
<Button
|
<Tooltip position="right" content={tooltipText}>
|
||||||
colorSchema="secondary"
|
<Button
|
||||||
variant="plain"
|
colorSchema="secondary"
|
||||||
size="xs"
|
variant="plain"
|
||||||
onClick={onClick}
|
size="xs"
|
||||||
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
|
onClick={onClick}
|
||||||
>
|
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
|
||||||
Show More
|
>
|
||||||
</Button>
|
Show More
|
||||||
</Tooltip>
|
</Button>
|
||||||
</>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
import { PermissionNode } from "../types";
|
||||||
|
|
||||||
export const createRoleNode = ({
|
export const createRoleNode = ({
|
||||||
subject,
|
subject,
|
||||||
environment
|
environment,
|
||||||
|
environments,
|
||||||
|
onSubjectChange,
|
||||||
|
onEnvironmentChange
|
||||||
}: {
|
}: {
|
||||||
subject: string;
|
subject: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
environments: TProjectEnvironmentsFolders;
|
||||||
|
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||||
|
onEnvironmentChange: (value: string) => void;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: `role-${subject}-${environment}`,
|
id: `role-${subject}-${environment}`,
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
subject,
|
subject,
|
||||||
environment
|
environment,
|
||||||
|
environments,
|
||||||
|
onSubjectChange,
|
||||||
|
onEnvironmentChange
|
||||||
},
|
},
|
||||||
type: PermissionNode.Role,
|
type: PermissionNode.Role,
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
|
import { ProjectPermissionSub } from "@app/context";
|
||||||
|
|
||||||
import { PermissionNode } from "../types";
|
import { PermissionNode } from "../types";
|
||||||
|
|
||||||
export const createShowMoreNode = ({
|
export const createShowMoreNode = ({
|
||||||
parentId,
|
parentId,
|
||||||
onClick,
|
onClick,
|
||||||
remaining
|
remaining,
|
||||||
|
subject
|
||||||
}: {
|
}: {
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
remaining: number;
|
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"}`;
|
const id = `show-more-${parentId || "root"}`;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -19,7 +35,11 @@ export const createShowMoreNode = ({
|
|||||||
onClick,
|
onClick,
|
||||||
remaining
|
remaining
|
||||||
},
|
},
|
||||||
width: 100,
|
width: 150,
|
||||||
height: 40
|
height,
|
||||||
|
style: {
|
||||||
|
background: "transparent",
|
||||||
|
border: "none"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import Dagre from "@dagrejs/dagre";
|
|||||||
import { Edge, Node } from "@xyflow/react";
|
import { Edge, Node } from "@xyflow/react";
|
||||||
|
|
||||||
export const positionElements = (nodes: Node[], edges: Edge[]) => {
|
export const positionElements = (nodes: Node[], edges: Edge[]) => {
|
||||||
const regularNodes = nodes.filter((node) => node.type !== "showMoreButton");
|
|
||||||
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
|
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
|
||||||
|
const showMoreParentIds = new Set(
|
||||||
|
showMoreNodes.map((node) => node.data.parentId).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
const nodeMap: Record<string, Node> = {};
|
const nodeMap: Record<string, Node> = {};
|
||||||
const childrenMap: Record<string, string[]> = {};
|
const childrenMap: Record<string, string[]> = {};
|
||||||
@@ -17,75 +19,79 @@ export const positionElements = (nodes: Node[], edges: Edge[]) => {
|
|||||||
|
|
||||||
const dagre = new Dagre.graphlib.Graph({ directed: true })
|
const dagre = new Dagre.graphlib.Graph({ directed: true })
|
||||||
.setDefaultEdgeLabel(() => ({}))
|
.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));
|
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
|
||||||
|
|
||||||
regularNodes.forEach((node) => dagre.setNode(node.id, node));
|
|
||||||
|
|
||||||
Dagre.layout(dagre, {});
|
Dagre.layout(dagre, {});
|
||||||
|
|
||||||
const positionedNodes = regularNodes.map((node) => {
|
const positionedNodes = nodes.map((node) => {
|
||||||
const { x, y } = dagre.node(node.id);
|
const { x, y } = dagre.node(node.id);
|
||||||
|
|
||||||
const positionedNode = {
|
if (node.type === "role") {
|
||||||
...node,
|
return {
|
||||||
position: {
|
...node,
|
||||||
x: x - (node.width ? node.width / 2 : 0),
|
position: {
|
||||||
y: y - (node.height ? node.height / 2 : 0)
|
x: x - (node.width ? node.width / 2 : 0),
|
||||||
}
|
y: y - 150
|
||||||
};
|
}
|
||||||
|
};
|
||||||
nodeMap[node.id] = positionedNode;
|
}
|
||||||
|
|
||||||
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 parentNode = nodeMap[parentId] || positionedNodes[0];
|
|
||||||
const lastChildNode = findLastChildNode(parentId);
|
|
||||||
|
|
||||||
const referenceNode = lastChildNode || parentNode;
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
...node,
|
...node,
|
||||||
position: {
|
position: {
|
||||||
x: buttonX,
|
x: x - (node.width ? node.width / 2 : 0),
|
||||||
y: buttonY
|
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 {
|
return {
|
||||||
nodes: [...positionedNodes, ...positionedShowMoreNodes],
|
nodes: positionedNodes,
|
||||||
edges
|
edges
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user