forked from baron/baron-sso
조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인
This commit is contained in:
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal file
388
orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type OrgNode,
|
||||
buildOrgSelectionOptions,
|
||||
clampScale,
|
||||
getOrgNodeHeaderFill,
|
||||
getSemanticZoomMode,
|
||||
layoutForest,
|
||||
} from "./OrgChartPage";
|
||||
|
||||
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
level,
|
||||
members: [],
|
||||
children,
|
||||
totalCount: 0,
|
||||
totalMemberIds: new Set<string>(),
|
||||
companyCode: id,
|
||||
type: level === 0 ? "COMPANY" : "USER_GROUP",
|
||||
};
|
||||
}
|
||||
|
||||
function member(id: string) {
|
||||
return {
|
||||
id,
|
||||
email: `${id}@example.com`,
|
||||
name: id,
|
||||
role: "user",
|
||||
status: "active",
|
||||
companyCode: "root",
|
||||
grade: "사원",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function tenantNode(
|
||||
id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
slug: string,
|
||||
children = [],
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
children,
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
recursiveMemberCount: 0,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
updatedAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeBoundsAspectRatio(
|
||||
nodes: ReturnType<typeof layoutForest>["nodes"],
|
||||
) {
|
||||
const minX = Math.min(...nodes.map((node) => node.x));
|
||||
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
|
||||
const minY = Math.min(...nodes.map((node) => node.y));
|
||||
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
|
||||
|
||||
return (maxX - minX) / (maxY - minY);
|
||||
}
|
||||
|
||||
describe("org chart layout", () => {
|
||||
it("keeps small sibling groups horizontal in automatic mode", () => {
|
||||
const children = Array.from({ length: 4 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
|
||||
expect(new Set(childNodes.map((node) => node.y)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("uses member columns in node bounds when member count exceeds five", () => {
|
||||
const compactMembers = Array.from({ length: 6 }, (_, index) =>
|
||||
member(`member-${index + 1}`),
|
||||
);
|
||||
const node = {
|
||||
...orgNode("root"),
|
||||
members: compactMembers,
|
||||
totalCount: compactMembers.length,
|
||||
totalMemberIds: new Set(compactMembers.map((item) => item.id)),
|
||||
};
|
||||
const layout = layoutForest([node], new Set());
|
||||
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootNode?.width).toBeGreaterThan(340);
|
||||
expect(rootNode?.height).toBeLessThan(42 + 24 + 6 * 24);
|
||||
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
|
||||
});
|
||||
|
||||
it("adds one member column per five-member quotient", () => {
|
||||
const tenMembers = Array.from({ length: 10 }, (_, index) =>
|
||||
member(`member-${index + 1}`),
|
||||
);
|
||||
const sixMembers = tenMembers.slice(0, 6);
|
||||
const sixLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("six"),
|
||||
members: sixMembers,
|
||||
totalCount: sixMembers.length,
|
||||
totalMemberIds: new Set(sixMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const tenLayout = layoutForest(
|
||||
[
|
||||
{
|
||||
...orgNode("ten"),
|
||||
members: tenMembers,
|
||||
totalCount: tenMembers.length,
|
||||
totalMemberIds: new Set(tenMembers.map((item) => item.id)),
|
||||
},
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
|
||||
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
|
||||
|
||||
expect(sixNode?.width).toBeGreaterThan(340);
|
||||
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
|
||||
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
|
||||
expect(tenLayout.width).toBeGreaterThan(sixLayout.width);
|
||||
});
|
||||
|
||||
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
||||
const children = Array.from({ length: 13 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
const childSpan =
|
||||
Math.max(...childNodes.map((node) => node.x + node.width)) -
|
||||
Math.min(...childNodes.map((node) => node.x));
|
||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||
|
||||
expect(childNodes).toHaveLength(13);
|
||||
expect(uniqueChildRows.size).toBeGreaterThan(1);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
|
||||
expect(
|
||||
layout.edges.filter((edge) => edge.key.startsWith("root->")),
|
||||
).toHaveLength(13);
|
||||
expect(
|
||||
layout.edges.filter(
|
||||
(edge) => edge.key.startsWith("root->") && edge.visibleByDefault,
|
||||
),
|
||||
).toHaveLength(new Set(childNodes.map((node) => node.x)).size);
|
||||
});
|
||||
|
||||
it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => {
|
||||
const children = Array.from({ length: 5 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set());
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
|
||||
|
||||
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
|
||||
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
|
||||
expect(aspectRatio).toBeLessThanOrEqual(1.61);
|
||||
});
|
||||
|
||||
it("keeps direct siblings on one level in top-down mode", () => {
|
||||
const children = Array.from({ length: 13 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "topDown",
|
||||
});
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
|
||||
expect(childNodes).toHaveLength(13);
|
||||
expect(uniqueChildRows.size).toBe(1);
|
||||
});
|
||||
|
||||
it("places children in three fixed columns with centered parent edges", () => {
|
||||
const children = Array.from({ length: 10 }, (_, index) =>
|
||||
orgNode(`child-${index + 1}`, [], 1),
|
||||
);
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const childNodes = layout.nodes.filter((node) =>
|
||||
node.node.id.startsWith("child-"),
|
||||
);
|
||||
const uniqueChildColumns = new Set(childNodes.map((node) => node.x));
|
||||
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
|
||||
const rootEdges = layout.edges.filter((edge) =>
|
||||
edge.key.startsWith("root->"),
|
||||
);
|
||||
|
||||
expect(uniqueChildColumns.size).toBe(3);
|
||||
expect(uniqueChildRows.size).toBe(4);
|
||||
expect(rootEdges).toHaveLength(10);
|
||||
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("places the deepest child subtree in the first multi-column section", () => {
|
||||
const children = [
|
||||
orgNode("shallow-1", [], 1),
|
||||
orgNode("shallow-2", [], 1),
|
||||
orgNode("shallow-3", [], 1),
|
||||
orgNode(
|
||||
"deep",
|
||||
[
|
||||
orgNode(
|
||||
"deep-branch",
|
||||
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
|
||||
2,
|
||||
),
|
||||
],
|
||||
1,
|
||||
),
|
||||
orgNode("shallow-4", [], 1),
|
||||
orgNode("shallow-5", [], 1),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const rootEdges = layout.edges.filter((edge) =>
|
||||
edge.key.startsWith("root->"),
|
||||
);
|
||||
|
||||
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
|
||||
});
|
||||
|
||||
it("centers a parent over the full child span in multi-column mode", () => {
|
||||
const children = [
|
||||
orgNode(
|
||||
"deep",
|
||||
[
|
||||
orgNode(
|
||||
"deep-branch",
|
||||
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
|
||||
2,
|
||||
),
|
||||
],
|
||||
1,
|
||||
),
|
||||
...Array.from({ length: 9 }, (_, index) =>
|
||||
orgNode(`shallow-${index + 1}`, [], 1),
|
||||
),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "threeColumn",
|
||||
});
|
||||
const rootNode = layout.nodes.find((node) => node.node.id === "root");
|
||||
const directChildren = layout.nodes.filter((node) => node.node.level === 1);
|
||||
const childSpanCenter =
|
||||
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
|
||||
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
|
||||
2;
|
||||
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
|
||||
});
|
||||
|
||||
it("centers parents above the tidy child span", () => {
|
||||
const children = [
|
||||
orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1),
|
||||
orgNode("middle", [], 1),
|
||||
orgNode(
|
||||
"right",
|
||||
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
];
|
||||
const layout = layoutForest([orgNode("root", children)], new Set(), {
|
||||
childLayoutMode: "topDown",
|
||||
});
|
||||
const rootNode = layout.nodes.find((node) => node.node.id === "root");
|
||||
const directChildren = layout.nodes.filter((node) =>
|
||||
["left", "middle", "right"].includes(node.node.id),
|
||||
);
|
||||
const childSpanCenter =
|
||||
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
|
||||
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
|
||||
2;
|
||||
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
|
||||
|
||||
expect(rootNode).toBeDefined();
|
||||
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
|
||||
});
|
||||
|
||||
it("keeps compressed subtrees from overlapping on shared vertical bands", () => {
|
||||
const layout = layoutForest(
|
||||
[
|
||||
orgNode("root", [
|
||||
orgNode(
|
||||
"left",
|
||||
[orgNode("left-a", [], 2), orgNode("left-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
orgNode(
|
||||
"right",
|
||||
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
|
||||
1,
|
||||
),
|
||||
]),
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
for (const node of layout.nodes) {
|
||||
for (const other of layout.nodes) {
|
||||
if (node.node.id >= other.node.id) continue;
|
||||
const verticalOverlap =
|
||||
node.y < other.y + other.height && other.y < node.y + node.height;
|
||||
const horizontalOverlap =
|
||||
node.x < other.x + other.width && other.x < node.x + node.width;
|
||||
|
||||
expect(
|
||||
verticalOverlap && horizontalOverlap,
|
||||
`${node.node.id} overlaps ${other.node.id}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps zoom limits wide enough for large SVG organization charts", () => {
|
||||
expect(clampScale(0.08)).toBe(0.08);
|
||||
expect(clampScale(5)).toBe(5);
|
||||
});
|
||||
|
||||
it("switches semantic zoom modes from overview to detail", () => {
|
||||
expect(getSemanticZoomMode(0.12)).toBe("overview");
|
||||
expect(getSemanticZoomMode(0.4)).toBe("compact");
|
||||
expect(getSemanticZoomMode(0.8)).toBe("detail");
|
||||
});
|
||||
|
||||
it("uses distinct header fills by organization depth", () => {
|
||||
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
|
||||
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
|
||||
expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d");
|
||||
expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d");
|
||||
expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf");
|
||||
expect(getOrgNodeHeaderFill(1, "saman")).not.toBe(
|
||||
getOrgNodeHeaderFill(0, "saman"),
|
||||
);
|
||||
expect(getOrgNodeHeaderFill(2, "saman")).not.toBe(
|
||||
getOrgNodeHeaderFill(1, "saman"),
|
||||
);
|
||||
});
|
||||
|
||||
it("orders top organization choices by the hanmac family policy", () => {
|
||||
const familyRoot = tenantNode(
|
||||
"family",
|
||||
"COMPANY_GROUP",
|
||||
"한맥가족",
|
||||
"hanmac-family",
|
||||
[
|
||||
tenantNode("saman", "COMPANY", "삼안", "saman"),
|
||||
tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"),
|
||||
tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"),
|
||||
tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
|
||||
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { GitBranch, Network, PanelTop } from "lucide-react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/chart", label: "조직도", icon: Network },
|
||||
@@ -8,9 +8,22 @@ const navItems = [
|
||||
];
|
||||
|
||||
export function OrgFrontLayout() {
|
||||
const location = useLocation();
|
||||
const isChartRoute =
|
||||
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div
|
||||
className={
|
||||
isChartRoute
|
||||
? "flex h-screen flex-col overflow-hidden bg-background text-foreground"
|
||||
: "min-h-screen bg-background text-foreground"
|
||||
}
|
||||
>
|
||||
<header
|
||||
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
|
||||
data-testid="orgfront-topbar"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
@@ -40,7 +53,14 @@ export function OrgFrontLayout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-5">
|
||||
<main
|
||||
className={
|
||||
isChartRoute
|
||||
? "min-h-0 flex-1 overflow-hidden"
|
||||
: "mx-auto max-w-7xl px-4 py-5"
|
||||
}
|
||||
data-testid="orgfront-main"
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ function PickerScenarioControls({
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">tenant ID</span>
|
||||
<span className="block text-muted-foreground">tenant slug</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
onChange={(event) =>
|
||||
@@ -68,7 +68,7 @@ function PickerScenarioControls({
|
||||
tenantId: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="company-baron"
|
||||
placeholder="saman"
|
||||
type="text"
|
||||
value={options.tenantId}
|
||||
/>
|
||||
|
||||
@@ -334,6 +334,7 @@ export function OrgPickerEmbedPage() {
|
||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||
const tenantId =
|
||||
searchParams.get("tenantSlug") ||
|
||||
searchParams.get("tenantId") ||
|
||||
searchParams.get("companyTenantId") ||
|
||||
undefined;
|
||||
@@ -615,7 +616,7 @@ export function OrgPickerPage() {
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm font-medium">
|
||||
<span className="block text-muted-foreground">tenant ID</span>
|
||||
<span className="block text-muted-foreground">tenant slug</span>
|
||||
<input
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3"
|
||||
onChange={(event) =>
|
||||
@@ -624,7 +625,7 @@ export function OrgPickerPage() {
|
||||
tenantId: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="company-baron"
|
||||
placeholder="saman"
|
||||
type="text"
|
||||
value={options.tenantId}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user