계층 표시 테스트 코드 추가
This commit is contained in:
1 parent
ca45a14bae
commit
f02ba3cbbd
6 files changed
+271
-58
No files matched your search
@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
|
||||
<div id='root'></div>
|
||||
</body>
|
||||
</html>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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 truncated
|
||||
@@ -65,11 +65,7 @@ import {
|
||||
updateUser,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
type TenantNode = TenantSummary & {
|
||||
children: TenantNode[];
|
||||
recursiveMemberCount: number;
|
||||
};
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
@@ -779,58 +775,10 @@ function TenantUserGroupsTab() {
|
||||
|
||||
const allTenants = data?.items ?? [];
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||
|
||||
const tenantMap = new Map<string, TenantNode>();
|
||||
for (const t of allTenants) {
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: t.memberCount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Build initial children relations
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId) {
|
||||
const parent = tenantMap.get(t.parentId);
|
||||
const child = tenantMap.get(t.id);
|
||||
if (parent && child) {
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate recursive counts
|
||||
const calculateRecursive = (node: TenantNode): number => {
|
||||
let total = node.memberCount || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all root nodes (those without parent or top-level in current context)
|
||||
for (const node of tenantMap.values()) {
|
||||
// We only strictly need to calculate from the top-most nodes to cover everything
|
||||
if (!node.parentId) {
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
const base = tenantMap.get(tenantId);
|
||||
if (base) {
|
||||
calculateRecursive(base);
|
||||
}
|
||||
|
||||
return {
|
||||
currentBase: base || null,
|
||||
subTree: base ? base.children : [],
|
||||
};
|
||||
}, [allTenants, tenantId]);
|
||||
const { currentBase, subTree } = useMemo(
|
||||
() => buildTenantFullTree(allTenants, tenantId),
|
||||
[allTenants, tenantId],
|
||||
);
|
||||
|
||||
const handleAdd = (id: string) =>
|
||||
updateParentMutation.mutate({ id, parentId: tenantId });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
import { buildTenantFullTree } from "./tenantTree";
|
||||
|
||||
describe("tenantTree utility", () => {
|
||||
const mockTenants: TenantSummary[] = [
|
||||
{
|
||||
id: "root-1",
|
||||
name: "Root",
|
||||
slug: "root",
|
||||
type: "COMPANY",
|
||||
memberCount: 10,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child 1",
|
||||
slug: "child-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 5,
|
||||
parentId: "root-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "grandchild-1",
|
||||
name: "Grandchild 1",
|
||||
slug: "grandchild-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 2,
|
||||
parentId: "child-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
it("calculates recursive member counts correctly", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "root-1");
|
||||
|
||||
expect(currentBase).not.toBeNull();
|
||||
if (currentBase) {
|
||||
// Direct: 10, Child: 5, Grandchild: 2 -> Total: 17
|
||||
expect(currentBase.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase.children).toHaveLength(1);
|
||||
|
||||
const child = currentBase.children[0];
|
||||
// Direct: 5, Grandchild: 2 -> Total: 7
|
||||
expect(child.recursiveMemberCount).toBe(7);
|
||||
expect(child.children).toHaveLength(1);
|
||||
|
||||
const grandchild = child.children[0];
|
||||
// Direct: 2 -> Total: 2
|
||||
expect(grandchild.recursiveMemberCount).toBe(2);
|
||||
expect(grandchild.children).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null currentBase if rootId is not found", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
|
||||
expect(currentBase).toBeNull();
|
||||
});
|
||||
|
||||
it("builds correct structure with multiple roots", () => {
|
||||
const multiRootTenants: TenantSummary[] = [
|
||||
...mockTenants,
|
||||
{
|
||||
id: "root-2",
|
||||
name: "Root 2",
|
||||
slug: "root-2",
|
||||
type: "COMPANY",
|
||||
memberCount: 3,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
const { subTree } = buildTenantFullTree(multiRootTenants);
|
||||
expect(subTree).toHaveLength(2);
|
||||
expect(subTree.map(n => n.id)).toContain("root-1");
|
||||
expect(subTree.map(n => n.id)).toContain("root-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
|
||||
export type TenantNode = TenantSummary & {
|
||||
children: TenantNode[];
|
||||
recursiveMemberCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree from a flat list of tenants and calculates
|
||||
* direct and recursive member counts for each node.
|
||||
*/
|
||||
export function buildTenantFullTree(
|
||||
allTenants: TenantSummary[],
|
||||
rootId?: string,
|
||||
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||
|
||||
const tenantMap = new Map<string, TenantNode>();
|
||||
for (const t of allTenants) {
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: t.memberCount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Build initial children relations
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId) {
|
||||
const parent = tenantMap.get(t.parentId);
|
||||
const child = tenantMap.get(t.id);
|
||||
if (parent && child) {
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate recursive counts
|
||||
const calculateRecursive = (node: TenantNode): number => {
|
||||
let total = node.memberCount || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If a specific rootId is provided, find and return its subtree
|
||||
if (rootId) {
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
}
|
||||
return { currentBase: null, subTree: [] };
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
return { currentBase: null, subTree: roots };
|
||||
}
|
||||
@@ -94,4 +94,107 @@ test.describe("Tenants Management", () => {
|
||||
await page.fill("input >> nth=0", "Valid Name");
|
||||
await expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("should show organization hierarchy and member list distinction", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mock parent tenant and its children
|
||||
const mockTenants = [
|
||||
{
|
||||
id: "parent-1",
|
||||
name: "Parent Org",
|
||||
slug: "parent-slug",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 5,
|
||||
parentId: null,
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child Team",
|
||||
slug: "child-slug",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 3,
|
||||
parentId: "parent-1",
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: mockTenants,
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock members for parent and child
|
||||
await page.route(
|
||||
"**/api/v1/admin/users?*companyCode=parent-slug*",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [{ id: "u1", name: "User One", email: "u1@parent.com" }],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/api/v1/admin/users?*companyCode=child-slug*",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [{ id: "u2", name: "User Two", email: "u2@child.com" }],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/tenants/parent-1/organization");
|
||||
|
||||
// Wait for the table to appear
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
|
||||
// Check if hierarchy shows correctly
|
||||
await expect(page.locator("table")).toContainText("Parent Org");
|
||||
await expect(page.locator("table")).toContainText("Child Team");
|
||||
|
||||
// Check if member counts (Direct/Total) are displayed
|
||||
// Parent should have Direct 5, Total 8
|
||||
const parentRow = page.locator("tr", { hasText: "Parent Org" });
|
||||
await expect(parentRow).toContainText("5"); // Direct
|
||||
await expect(parentRow).toContainText("8"); // Total (5 + 3)
|
||||
|
||||
// Check for either English or Korean labels
|
||||
const hasDirectLabel = await parentRow.evaluate(el =>
|
||||
el.textContent?.includes("Direct") || el.textContent?.includes("소속")
|
||||
);
|
||||
const hasTotalLabel = await parentRow.evaluate(el =>
|
||||
el.textContent?.includes("Total") || el.textContent?.includes("전체")
|
||||
);
|
||||
expect(hasDirectLabel).toBe(true);
|
||||
expect(hasTotalLabel).toBe(true);
|
||||
|
||||
// Open Member List Dialog - Click the members count button
|
||||
const memberButton = parentRow.getByRole("button").filter({ hasText: /Direct|소속/ });
|
||||
await memberButton.click();
|
||||
|
||||
// Check Tabs in Member List Dialog
|
||||
// Use regex to match either language, ignoring the count suffix
|
||||
await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible();
|
||||
await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible();
|
||||
|
||||
// Direct Members Tab should show parent's user
|
||||
await expect(page.locator("role=dialog")).toContainText("u1@parent.com");
|
||||
|
||||
// Switch to Descendant Members Tab
|
||||
await page.click('button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")');
|
||||
await expect(page.locator("role=dialog")).toContainText("u2@child.com");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user