forked from baron/baron-sso
Fix org chart manager ordering and title wrapping
This commit is contained in:
@@ -214,7 +214,7 @@ function AppLayout() {
|
|||||||
});
|
});
|
||||||
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||||
{ includeInternal: true },
|
{ includeInternal: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Splice optional menus in a standard order
|
// Splice optional menus in a standard order
|
||||||
|
|||||||
@@ -71,18 +71,20 @@ describe("orgChartPicker", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the admin chart navigation URL with internal visibility enabled", () => {
|
it("builds the admin chart navigation URL without internal visibility by default", () => {
|
||||||
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
|
||||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can build chart navigation URL without internal visibility", () => {
|
it("can build chart navigation URL with internal visibility when explicitly requested", () => {
|
||||||
expect(
|
expect(
|
||||||
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
|
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
|
||||||
includeInternal: false,
|
includeInternal: true,
|
||||||
}),
|
}),
|
||||||
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
|
).toBe(
|
||||||
|
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export function buildAuthenticatedOrgChartUserMultiPickerUrl(baseUrl?: string) {
|
|||||||
|
|
||||||
export function buildAuthenticatedOrgChartUrl(
|
export function buildAuthenticatedOrgChartUrl(
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
options: OrgChartLoginOptions = { includeInternal: true },
|
options: OrgChartLoginOptions = { includeInternal: false },
|
||||||
) {
|
) {
|
||||||
const normalizedBase =
|
const normalizedBase =
|
||||||
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
|
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ test.describe("Authentication", () => {
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
|
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
/\/login\?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue$/,
|
/\/login\?auto=1&returnTo=%2Fchart$/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,63 @@ describe("org chart layout", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("places organization managers before higher rank members in the same organization", () => {
|
||||||
|
const members = [
|
||||||
|
{ ...member("executive"), name: "임원", grade: "전무이사" },
|
||||||
|
{
|
||||||
|
...member("manager"),
|
||||||
|
name: "조직장",
|
||||||
|
grade: "사원",
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "root",
|
||||||
|
isManager: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ ...member("principal"), name: "수석", grade: "수석연구원" },
|
||||||
|
];
|
||||||
|
const layout = layoutForest(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...orgNode("root"),
|
||||||
|
members,
|
||||||
|
totalCount: members.length,
|
||||||
|
totalMemberIds: new Set(members.map((item) => item.id)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const rootNode = layout.nodes.find((item) => item.node.id === "root");
|
||||||
|
|
||||||
|
expect(rootNode?.members.map((item) => item.id)).toEqual([
|
||||||
|
"manager",
|
||||||
|
"executive",
|
||||||
|
"principal",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands node width for long organization names even without members", () => {
|
||||||
|
const shortLayout = layoutForest([orgNode("short")], new Set());
|
||||||
|
const longLayout = layoutForest(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...orgNode("long"),
|
||||||
|
name: "초장문 조직 명칭 표시 검증을 위한 구조물 디지털 전환 통합 운영 센터",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const shortNode = shortLayout.nodes.find(
|
||||||
|
(item) => item.node.id === "short",
|
||||||
|
);
|
||||||
|
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
|
||||||
|
|
||||||
|
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
|
||||||
const children = Array.from({ length: 13 }, (_, index) =>
|
const children = Array.from({ length: 13 }, (_, index) =>
|
||||||
orgNode(`child-${index + 1}`, [], 1),
|
orgNode(`child-${index + 1}`, [], 1),
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ const MEMBER_CARD_BASE_CHAR_COUNT = 8;
|
|||||||
const MEMBER_CARD_CHAR_WIDTH = 12;
|
const MEMBER_CARD_CHAR_WIDTH = 12;
|
||||||
const MEMBER_CARD_TEXT_PADDING_X = 28;
|
const MEMBER_CARD_TEXT_PADDING_X = 28;
|
||||||
const MEMBER_COLUMN_MAX_WIDTH = 280;
|
const MEMBER_COLUMN_MAX_WIDTH = 280;
|
||||||
|
const NODE_HEADER_CHAR_WIDTH = 17;
|
||||||
|
const NODE_HEADER_WRAP_THRESHOLD = 420;
|
||||||
|
const NODE_HEADER_TEXT_PADDING_X = 112;
|
||||||
|
const NODE_HEADER_TYPE_BADGE_WIDTH = 48;
|
||||||
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
|
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
|
||||||
const MAX_MEMBER_COLUMN_COUNT = 8;
|
const MAX_MEMBER_COLUMN_COUNT = 8;
|
||||||
const ROOT_GAP_X = 120;
|
const ROOT_GAP_X = 120;
|
||||||
@@ -154,6 +158,24 @@ function getRankWeight(
|
|||||||
return getOrgRankWeight(profile.grade);
|
return getOrgRankWeight(profile.grade);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getManagerWeight(
|
||||||
|
user: UserSummary,
|
||||||
|
tenant?: { id: string; slug: string },
|
||||||
|
) {
|
||||||
|
return getUserOrgProfile(user, tenant).isManager ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareOrgMembers(
|
||||||
|
a: UserSummary,
|
||||||
|
b: UserSummary,
|
||||||
|
tenant?: { id: string; slug: string },
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
getManagerWeight(a, tenant) - getManagerWeight(b, tenant) ||
|
||||||
|
getRankWeight(a, tenant) - getRankWeight(b, tenant)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getComplementaryColor(hexColor: string) {
|
function getComplementaryColor(hexColor: string) {
|
||||||
const normalized = hexColor.trim().replace("#", "");
|
const normalized = hexColor.trim().replace("#", "");
|
||||||
if (!/^[\da-f]{6}$/i.test(normalized)) return "#f59e0b";
|
if (!/^[\da-f]{6}$/i.test(normalized)) return "#f59e0b";
|
||||||
@@ -246,14 +268,37 @@ function getMemberRowCount(memberCount: number, memberColumnWidth?: number) {
|
|||||||
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
|
return getMemberGridMetrics(memberCount, memberColumnWidth).rowCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeWidth(members: UserSummary[], memberColumnWidth: number) {
|
function getNodeHeaderWidth(node: OrgNode) {
|
||||||
|
const typeBadgeWidth = node.orgUnitType ? NODE_HEADER_TYPE_BADGE_WIDTH : 0;
|
||||||
|
const titleTextWidth =
|
||||||
|
getDisplayTextWidthUnit(node.name) * NODE_HEADER_CHAR_WIDTH;
|
||||||
|
const oneLineWidth =
|
||||||
|
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth;
|
||||||
|
if (oneLineWidth <= NODE_HEADER_WRAP_THRESHOLD) {
|
||||||
|
return Math.ceil(oneLineWidth);
|
||||||
|
}
|
||||||
|
const estimatedTitleWidth =
|
||||||
|
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth / 2;
|
||||||
|
return Math.ceil(estimatedTitleWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeWidth(
|
||||||
|
node: OrgNode,
|
||||||
|
members: UserSummary[],
|
||||||
|
memberColumnWidth: number,
|
||||||
|
) {
|
||||||
const columnCount = getMemberColumnCount(members.length, memberColumnWidth);
|
const columnCount = getMemberColumnCount(members.length, memberColumnWidth);
|
||||||
if (columnCount <= 1) {
|
if (columnCount <= 1) {
|
||||||
return Math.max(NODE_WIDTH, NODE_PADDING_Y * 2 + memberColumnWidth);
|
return Math.max(
|
||||||
|
NODE_WIDTH,
|
||||||
|
getNodeHeaderWidth(node),
|
||||||
|
NODE_PADDING_Y * 2 + memberColumnWidth,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
NODE_WIDTH,
|
NODE_WIDTH,
|
||||||
|
getNodeHeaderWidth(node),
|
||||||
NODE_PADDING_Y * 2 +
|
NODE_PADDING_Y * 2 +
|
||||||
columnCount * memberColumnWidth +
|
columnCount * memberColumnWidth +
|
||||||
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
(columnCount - 1) * MEMBER_COLUMN_GAP,
|
||||||
@@ -775,9 +820,8 @@ function layoutTree(
|
|||||||
options: OrgChartLayoutOptions,
|
options: OrgChartLayoutOptions,
|
||||||
): ChartLayout {
|
): ChartLayout {
|
||||||
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
|
const tenantIdentity = { id: node.id, slug: node.companyCode ?? "" };
|
||||||
const members = [...node.members].sort(
|
const members = [...node.members].sort((a, b) =>
|
||||||
(a, b) =>
|
compareOrgMembers(a, b, tenantIdentity),
|
||||||
getRankWeight(a, tenantIdentity) - getRankWeight(b, tenantIdentity),
|
|
||||||
);
|
);
|
||||||
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
|
const memberColumnWidth = getMemberColumnWidth(members, tenantIdentity);
|
||||||
const nodeHeight = getNodeHeight(members, memberColumnWidth);
|
const nodeHeight = getNodeHeight(members, memberColumnWidth);
|
||||||
@@ -802,7 +846,7 @@ function layoutTree(
|
|||||||
const childCenters = childRoots.map(
|
const childCenters = childRoots.map(
|
||||||
(childRoot) => childRoot.x + childRoot.width / 2,
|
(childRoot) => childRoot.x + childRoot.width / 2,
|
||||||
);
|
);
|
||||||
const nodeWidth = getNodeWidth(members, memberColumnWidth);
|
const nodeWidth = getNodeWidth(node, members, memberColumnWidth);
|
||||||
const firstChildCenter =
|
const firstChildCenter =
|
||||||
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
|
childCenters.length > 0 ? Math.min(...childCenters) : nodeWidth / 2;
|
||||||
const lastChildCenter =
|
const lastChildCenter =
|
||||||
@@ -2089,8 +2133,14 @@ function SvgOrgNode({
|
|||||||
const showCompactGlyph = !showNodeName;
|
const showCompactGlyph = !showNodeName;
|
||||||
const titleClass =
|
const titleClass =
|
||||||
node.level <= 1
|
node.level <= 1
|
||||||
? "text-[17px] font-black leading-tight"
|
? "text-[17px] font-black leading-[1.05]"
|
||||||
: "text-[14px] font-extrabold leading-tight";
|
: "text-[14px] font-extrabold leading-[1.05]";
|
||||||
|
const titleStyle: React.CSSProperties = {
|
||||||
|
display: "-webkit-box",
|
||||||
|
overflow: "hidden",
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<foreignObject
|
<foreignObject
|
||||||
@@ -2117,7 +2167,10 @@ function SvgOrgNode({
|
|||||||
{node.orgUnitType}
|
{node.orgUnitType}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={`${titleClass} min-w-0 truncate`}>
|
<div
|
||||||
|
className={`${titleClass} min-w-0 break-words`}
|
||||||
|
style={titleStyle}
|
||||||
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
|
|||||||
appointment?.isAdmin === true ||
|
appointment?.isAdmin === true ||
|
||||||
appointment?.isManager === true ||
|
appointment?.isManager === true ||
|
||||||
appointment?.isOwner === true,
|
appointment?.isOwner === true,
|
||||||
|
isManager: appointment?.isManager === true,
|
||||||
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
|
||||||
position: appointment?.position || normalizeText(user.position),
|
position: appointment?.position || normalizeText(user.position),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ test("org chart displays user names with short grade aliases and no job details"
|
|||||||
await expect(svg.getByText(/팀장|Platform Engineer/)).toHaveCount(0);
|
await expect(svg.getByText(/팀장|Platform Engineer/)).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("org chart orders top executive members by rank priority", async ({
|
test("org chart orders managers before top executive members by rank priority", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const executiveUser = (id: string, name: string, grade: string) => ({
|
const executiveUser = (id: string, name: string, grade: string) => ({
|
||||||
@@ -293,6 +293,17 @@ test("org chart orders top executive members by rank priority", async ({
|
|||||||
executiveUser("u-president", "President", "사장"),
|
executiveUser("u-president", "President", "사장"),
|
||||||
executiveUser("u-chair", "Chair", "회장"),
|
executiveUser("u-chair", "Chair", "회장"),
|
||||||
executiveUser("u-director", "Director", "전무"),
|
executiveUser("u-director", "Director", "전무"),
|
||||||
|
{
|
||||||
|
...executiveUser("u-manager", "Team Manager", "사원"),
|
||||||
|
metadata: {
|
||||||
|
additionalAppointments: [
|
||||||
|
{
|
||||||
|
tenantSlug: "engineering",
|
||||||
|
isManager: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -312,6 +323,7 @@ test("org chart orders top executive members by rank priority", async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(orderedMemberIds).toEqual([
|
expect(orderedMemberIds).toEqual([
|
||||||
|
"orgchart-member-u-manager",
|
||||||
"orgchart-member-u-chair",
|
"orgchart-member-u-chair",
|
||||||
"orgchart-member-u-president",
|
"orgchart-member-u-president",
|
||||||
"orgchart-member-u-vice-chair",
|
"orgchart-member-u-vice-chair",
|
||||||
@@ -321,6 +333,42 @@ test("org chart orders top executive members by rank priority", async ({
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("org chart expands organization node width so long names are not clipped", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const longName =
|
||||||
|
"초장문 조직 명칭 표시 검증을 위한 구조물 디지털 전환 통합 운영 센터";
|
||||||
|
|
||||||
|
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sharedWith: "Playwright",
|
||||||
|
tenants: [
|
||||||
|
tenant("group", "HMAC Group", "hmac"),
|
||||||
|
tenant("long-name", longName, "long-name", "group"),
|
||||||
|
],
|
||||||
|
users: [user("u-long", "Long Name User", "long-name")],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/chart?token=long-org-name");
|
||||||
|
|
||||||
|
const longNode = page.locator('[data-testid="orgchart-node-long-name"]');
|
||||||
|
await expect(longNode).toBeVisible();
|
||||||
|
const title = longNode.getByText(longName, { exact: true });
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
|
||||||
|
const titleMetrics = await title.evaluate((element) => ({
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
}));
|
||||||
|
expect(titleMetrics.scrollWidth).toBeLessThanOrEqual(
|
||||||
|
titleMetrics.clientWidth + 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("org chart only highlights flagged member cards", async ({ page }) => {
|
test("org chart only highlights flagged member cards", async ({ page }) => {
|
||||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
|
|||||||
Reference in New Issue
Block a user