diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html index c67d9584..84d1d61f 100644 --- a/adminfront/playwright-report/index.html +++ b/adminfront/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 13bc0537..03533254 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -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(); - 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 }); diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts new file mode 100644 index 00000000..eb277ce7 --- /dev/null +++ b/adminfront/src/lib/tenantTree.test.ts @@ -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"); + }); +}); diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts new file mode 100644 index 00000000..36b5a4a2 --- /dev/null +++ b/adminfront/src/lib/tenantTree.ts @@ -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(); + 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 }; +} diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index da5b3862..253e1b76 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -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"); + }); }); diff --git a/image.png b/image.png new file mode 100644 index 00000000..f6fc697b Binary files /dev/null and b/image.png differ