1
0
forked from baron/baron-sso
Files
baron-sso/orgfront/src/sdk/org-context-chart/index.ts

983 lines
34 KiB
TypeScript

export type OrgContextMember = {
id?: string;
email: string;
name: string;
phone?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
isOwner?: boolean;
isLeader?: boolean;
isPrimary?: boolean;
};
export type OrgContextTenant = {
id: string;
type: string;
name: string;
slug: string;
parentId?: string | null;
status: string;
description: string;
domains: string[];
memberCount: number;
visibility: string;
orgUnitType?: string;
createdAt: string;
updatedAt: string;
members: OrgContextMember[];
};
export type OrgContextTreeNode = OrgContextTenant & {
children: OrgContextTreeNode[];
};
export type OrgContextResponse = {
schemaVersion: "baron.org-context.v1";
issuedAt: string;
scope: {
tenantId: string;
tenantSlug: string;
};
tree: OrgContextTreeNode;
tenants: OrgContextTenant[];
};
export type OrgChartNode = OrgContextTenant & {
children: OrgChartNode[];
depth: number;
path: string[];
};
export type OrgChartMember = OrgContextMember & {
tenantIds: string[];
};
export type OrgChartModel = {
root: OrgChartNode;
nodes: OrgChartNode[];
tenantsById: Map<string, OrgChartNode>;
tenantsBySlug: Map<string, OrgChartNode>;
membersByEmail: Map<string, OrgChartMember>;
response: OrgContextResponse;
};
export type OrgContextClientOptions = {
baseUrl: string;
credentials?: {
keyId: string;
keySecret: string;
};
fetch?: typeof fetch;
headers?: Record<string, string>;
};
export type FetchOrgContextOptions = {
tenantSlug?: string;
includeUsers?: boolean;
includeUserIds?: boolean;
};
export type OrgPickerSelection = {
id: string;
name: string;
type: "tenant" | "user";
};
export type OrgPickerVariant = "default" | "orgfront";
export type OrgPickerOptions = {
mode?: "single" | "multiple";
selectable?: "tenant" | "user" | "both";
includeDescendants?: boolean;
injectStyles?: boolean;
showDescendantToggle?: boolean;
variant?: OrgPickerVariant;
onCancel?: () => void;
onChange?: (selection: OrgPickerSelection[]) => void;
onConfirm?: (selection: OrgPickerSelection[]) => void;
};
export type OrgPickerController = {
cancel: () => void;
confirm: () => void;
destroy: () => void;
getSelection: () => OrgPickerSelection[];
};
const API_PATH = "/api/v1/integrations/org-context";
const DEFAULT_STYLE_ID = "baron-org-context-chart-default-style";
const DEFAULT_STYLE = `
.baron-org-chart,.baron-org-picker{box-sizing:border-box;color:#0f172a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}
.baron-org-chart *,.baron-org-picker *{box-sizing:border-box}
.baron-org-chart__tree{display:flex;min-width:100%;gap:24px;overflow:auto;padding:16px}
.baron-org-chart__node{min-width:220px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 10px 30px rgba(15,23,42,.08)}
.baron-org-chart__title{margin:0;padding:10px 12px;border-bottom:1px solid #e5e7eb;background:#f8fafc;font-size:14px;font-weight:700}
.baron-org-chart__meta{margin:0;padding:8px 12px;color:#64748b;font-size:12px}
.baron-org-chart__members{margin:0;padding:0 12px 12px 28px;color:#334155;font-size:12px}
.baron-org-chart__children{display:flex;gap:16px;margin:12px;padding:12px 0 0 16px;border-left:1px solid #d8dee9}
.baron-org-picker{width:100%;max-width:520px;border:1px solid #d8dee9;border-radius:8px;background:#fff;box-shadow:0 12px 34px rgba(15,23,42,.1);overflow:hidden}
.baron-org-picker__toolbar{display:flex;flex-direction:column;gap:10px;padding:12px;border-bottom:1px solid #e5e7eb;background:#f8fafc}
.baron-org-picker__search-wrap{position:relative}
.baron-org-picker__search-icon{pointer-events:none;position:absolute;left:12px;top:50%;width:16px;height:16px;transform:translateY(-50%);color:#64748b}
.baron-org-picker__search-icon::before{content:"";position:absolute;left:2px;top:2px;width:8px;height:8px;border:2px solid currentColor;border-radius:999px}
.baron-org-picker__search-icon::after{content:"";position:absolute;left:10px;top:11px;width:6px;height:2px;background:currentColor;border-radius:999px;transform:rotate(45deg);transform-origin:left center}
.baron-org-picker__search{width:100%;height:38px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;padding:0 10px;color:#0f172a;font:inherit;outline:none}
.baron-org-picker__search:focus{border-color:#24449c;box-shadow:0 0 0 3px rgba(36,68,156,.18)}
.baron-org-picker__controls{display:flex;align-items:center;justify-content:space-between;gap:12px;color:#475569;font-size:12px}
.baron-org-picker__descendants{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.baron-org-picker__summary{color:#64748b}
.baron-org-picker__clear{border:0;background:transparent;color:#24449c;cursor:pointer;font:inherit;font-weight:700;padding:2px 0}
.baron-org-picker__clear:hover{text-decoration:underline}
.baron-org-picker__list,.baron-org-picker__children{list-style:none;margin:0;padding:0}
.baron-org-picker__list{max-height:420px;overflow:auto;padding:8px}
.baron-org-picker__item{margin:0}
.baron-org-picker__children{margin-left:8px;border-left:1px solid #e5e7eb}
.baron-org-picker__row{display:flex;min-height:32px;align-items:center;gap:8px;border-radius:6px;padding:4px 8px;color:#0f172a;cursor:pointer}
.baron-org-picker__row:hover{background:#f1f5f9}
.baron-org-picker__row--selected{background:#e8eefc;color:#18327a}
.baron-org-picker__row--member{color:#334155;font-size:13px}
.baron-org-picker__label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.baron-org-picker__label-primary,.baron-org-picker__label-secondary{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.baron-org-picker__label-primary{font-weight:600;line-height:20px}
.baron-org-picker__label-secondary{color:#64748b;font-size:12px;line-height:20px}
.baron-org-picker input[type="checkbox"],.baron-org-picker input[type="radio"]{accent-color:#24449c}
.baron-org-picker__empty{padding:28px 12px;color:#64748b;text-align:center}
.baron-org-picker__toggle,.baron-org-picker__toggle-placeholder{display:grid;width:24px;height:24px;flex:0 0 24px;place-items:center;border:0;border-radius:4px;background:transparent;color:#64748b;font:inherit;line-height:1}
.baron-org-picker__toggle{cursor:pointer}
.baron-org-picker__toggle:hover{background:#e2e8f0}
.baron-org-picker__chevron{position:relative;display:block;width:16px;height:16px;color:currentColor}
.baron-org-picker__chevron::before{content:"";position:absolute;width:6px;height:6px;border-right:2px solid currentColor;border-bottom:2px solid currentColor}
.baron-org-picker__chevron--open::before{left:4px;top:3px;transform:rotate(45deg)}
.baron-org-picker__chevron--closed::before{left:3px;top:4px;transform:rotate(-45deg)}
.baron-org-picker__select{min-width:0;flex:1;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;outline:none;padding:0 4px}
.baron-org-picker__select:focus-visible{box-shadow:0 0 0 2px #3a98e5}
.baron-org-picker__footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e5e7eb;background:#fff;padding:8px 12px}
.baron-org-picker__actions{display:flex;align-items:center;gap:8px}
.baron-org-picker__button{display:inline-flex;height:36px;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border-radius:6px;border:1px solid #e5e7eb;background:#fff;color:#0f172a;padding:0 12px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
.baron-org-picker__button:hover{background:#f4b840;color:#0f172a}
.baron-org-picker__button--primary{border-color:#3a98e5;background:#3a98e5;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.12)}
.baron-org-picker__button--primary:hover{background:#2588d8;color:#fff}
.baron-org-picker__button:disabled{cursor:not-allowed;opacity:.5}
.baron-org-picker--orgfront{--boc-background:hsl(var(--background,0 0% 98%));--boc-foreground:hsl(var(--foreground,223 25% 12%));--boc-primary:hsl(var(--primary,209 79% 52%));--boc-secondary:hsl(var(--secondary,220 17% 94%));--boc-muted-foreground:hsl(var(--muted-foreground,223 15% 45%));--boc-accent:hsl(var(--accent,40 96% 62%));--boc-accent-foreground:hsl(var(--accent-foreground,223 25% 12%));--boc-border:hsl(var(--border,220 17% 90%));--boc-input:hsl(var(--input,220 17% 90%));--boc-ring:hsl(var(--ring,209 79% 52%));display:flex;height:100%;min-height:320px;max-width:none;flex-direction:column;border:0;border-radius:0;background:var(--boc-background);box-shadow:none;color:var(--boc-foreground);overflow:hidden}
.baron-org-picker--orgfront .baron-org-picker__toolbar{display:block;border-bottom:1px solid var(--boc-border);background:var(--boc-background);padding:8px}
.baron-org-picker--orgfront .baron-org-picker__toolbar-grid{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:8px}
.baron-org-picker--orgfront .baron-org-picker__search{height:36px;border-color:var(--boc-input);border-radius:6px;background:var(--boc-background);padding:0 12px 0 36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__search:focus{border-color:var(--boc-input);box-shadow:0 0 0 2px var(--boc-ring)}
.baron-org-picker--orgfront .baron-org-picker__controls{height:36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__descendants{height:36px;font-size:14px}
.baron-org-picker--orgfront .baron-org-picker__list{min-height:0;flex:1;max-height:none;overflow:auto;padding:12px}
.baron-org-picker--orgfront .baron-org-picker__children{margin-left:16px;border-left:0}
.baron-org-picker--orgfront .baron-org-picker__row{min-height:28px;gap:6px;border-radius:4px;padding:2px 6px 2px 4px;transition:background-color .15s,color .15s,box-shadow .15s}
.baron-org-picker--orgfront .baron-org-picker__row:hover{background:color-mix(in srgb,var(--boc-secondary) 50%,transparent)}
.baron-org-picker--orgfront .baron-org-picker__row--selected{background:color-mix(in srgb,var(--boc-primary) 15%,transparent);box-shadow:0 0 0 2px color-mix(in srgb,var(--boc-primary) 60%,transparent);color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__row--member{font-size:14px;color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__footer{border-top-color:var(--boc-border);background:var(--boc-background)}
.baron-org-picker--orgfront .baron-org-picker__summary{font-size:14px;color:var(--boc-muted-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button{border-color:var(--boc-input);background:var(--boc-background);color:var(--boc-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button:hover{background:var(--boc-accent);color:var(--boc-accent-foreground)}
.baron-org-picker--orgfront .baron-org-picker__button--primary{border-color:var(--boc-primary);background:var(--boc-primary);color:#fff}
.baron-org-picker--orgfront .baron-org-picker__empty{margin:12px;min-height:160px;border:1px dashed var(--boc-border);border-radius:6px;background:var(--boc-background);display:grid;place-items:center}
`;
export function createOrgContextClient(options: OrgContextClientOptions) {
const fetcher = options.fetch ?? globalThis.fetch;
if (!fetcher) {
throw new Error("A fetch implementation is required.");
}
return {
async fetchOrgContext(
query: FetchOrgContextOptions = {},
): Promise<OrgContextResponse> {
const url = new URL(API_PATH, normalizeBaseUrl(options.baseUrl));
appendQuery(url, "tenantSlug", query.tenantSlug);
appendQuery(url, "includeUsers", query.includeUsers);
appendQuery(url, "includeUserIds", query.includeUserIds);
const headers: Record<string, string> = {
Accept: "application/json",
...options.headers,
};
if (options.credentials) {
headers["X-Baron-Key-ID"] = options.credentials.keyId;
headers["X-Baron-Key-Secret"] = options.credentials.keySecret;
}
const response = await fetcher(url.toString(), { headers });
if (!response.ok) {
throw new Error(`Org context request failed with ${response.status}`);
}
const payload = (await response.json()) as OrgContextResponse;
if (payload.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${payload.schemaVersion}`,
);
}
return payload;
},
};
}
export function buildOrgChartModel(
response: OrgContextResponse,
): OrgChartModel {
if (response.schemaVersion !== "baron.org-context.v1") {
throw new Error(
`Unsupported org context schema: ${response.schemaVersion}`,
);
}
const nodes: OrgChartNode[] = [];
const tenantsById = new Map<string, OrgChartNode>();
const tenantsBySlug = new Map<string, OrgChartNode>();
const membersByEmail = new Map<string, OrgChartMember>();
const visit = (
node: OrgContextTreeNode,
depth: number,
ancestorPath: string[],
): OrgChartNode => {
const path = [...ancestorPath, node.id];
const chartNode: OrgChartNode = {
...node,
children: [],
depth,
path,
};
nodes.push(chartNode);
tenantsById.set(chartNode.id, chartNode);
tenantsBySlug.set(chartNode.slug, chartNode);
for (const member of chartNode.members) {
const key = member.email.toLowerCase();
const existing = membersByEmail.get(key);
if (existing) {
existing.tenantIds.push(chartNode.id);
} else {
membersByEmail.set(key, {
...member,
tenantIds: [chartNode.id],
});
}
}
chartNode.children = node.children.map((child) =>
visit(child, depth + 1, path),
);
return chartNode;
};
return {
root: visit(response.tree, 0, []),
nodes,
tenantsById,
tenantsBySlug,
membersByEmail,
response,
};
}
export function renderOrgChart(
container: HTMLElement,
model: OrgChartModel,
): { destroy: () => void } {
ensureDefaultStyles();
container.replaceChildren();
container.classList.add("baron-org-chart");
const root = document.createElement("div");
root.className = "baron-org-chart__tree";
root.append(renderChartNode(model.root));
container.append(root);
return {
destroy() {
container.replaceChildren();
container.classList.remove("baron-org-chart");
},
};
}
export function renderOrgPicker(
container: HTMLElement,
model: OrgChartModel,
options: OrgPickerOptions = {},
): OrgPickerController {
if (options.injectStyles !== false) {
ensureDefaultStyles();
}
const mode = options.mode ?? "single";
const selectable = options.selectable ?? "tenant";
const variant = options.variant ?? "default";
const isOrgfront = variant === "orgfront";
let includeDescendants =
options.includeDescendants ?? (isOrgfront && mode === "multiple");
let searchQuery = "";
const selected = new Map<string, OrgPickerSelection>();
const expanded = new Set(model.nodes.map((node) => node.id));
const showDescendantToggle = options.showDescendantToggle ?? true;
const currentSelection = () => Array.from(selected.values());
const emitChange = () => {
const selection = currentSelection();
options.onChange?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-change", {
bubbles: true,
detail: { selection },
}),
);
};
const emitConfirm = () => {
const selection = currentSelection();
options.onConfirm?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-confirm", {
bubbles: true,
detail: { selection },
}),
);
};
const emitCancel = () => {
options.onCancel?.();
container.dispatchEvent(
new CustomEvent("baron-org-picker-cancel", {
bubbles: true,
}),
);
};
const toggleSelection = (
selection: OrgPickerSelection,
checked: boolean,
descendants: OrgPickerSelection[],
) => {
if (mode === "single") {
selected.clear();
if (checked) {
selected.set(selectionKey(selection), selection);
}
emitChange();
rerender();
return;
}
const targets =
includeDescendants && selection.type === "tenant"
? [selection, ...descendants]
: [selection];
for (const target of targets) {
if (checked) {
selected.set(selectionKey(target), target);
} else {
selected.delete(selectionKey(target));
}
}
emitChange();
rerender();
};
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
const item = document.createElement("li");
item.className = "baron-org-picker__item";
const hasChildren = node.children.length > 0;
const tenantSelection: OrgPickerSelection = {
id: node.id,
name: node.name,
type: "tenant",
};
const row = document.createElement(isOrgfront ? "div" : "label");
row.className = "baron-org-picker__row";
if (selected.has(selectionKey(tenantSelection))) {
row.classList.add("baron-org-picker__row--selected");
}
row.style.paddingLeft = `${node.depth * 16}px`;
if (isOrgfront) {
row.append(createExpandToggle(node, hasChildren));
appendOrgfrontSelectionControl(row, node, tenantSelection);
} else {
if (selectable === "tenant" || selectable === "both") {
row.append(
createPickerInput({
mode,
selection: tenantSelection,
selected,
onToggle: (checked) =>
toggleSelection(
tenantSelection,
checked,
collectDescendantSelections(node, selectable),
),
}),
);
}
row.append(createLabelText(node.name, node.type));
}
item.append(row);
if (selectable === "user" || selectable === "both") {
for (const member of node.members) {
item.append(
renderMemberPickerRow(
member,
node,
mode,
selected,
(value) =>
toggleSelection(value, !selected.has(selectionKey(value)), []),
isOrgfront,
),
);
}
}
if (hasChildren && (!isOrgfront || expanded.has(node.id))) {
const children = document.createElement("ul");
children.className = "baron-org-picker__children";
for (const child of node.children) {
children.append(renderPickerNode(child));
}
item.append(children);
}
return item;
};
const appendOrgfrontSelectionControl = (
row: HTMLElement,
node: OrgChartNode,
tenantSelection: OrgPickerSelection,
) => {
const canSelect = selectable === "tenant" || selectable === "both";
if (!canSelect) {
row.append(createOrgfrontLabelText(node.name));
return;
}
if (mode === "multiple") {
row.append(
createPickerInput({
mode,
selection: tenantSelection,
selected,
onToggle: (checked) =>
toggleSelection(
tenantSelection,
checked,
collectDescendantSelections(node, selectable),
),
}),
);
row.append(createOrgfrontLabelText(node.name));
return;
}
const button = document.createElement("button");
button.className = "baron-org-picker__select";
button.dataset.baronOrgPickerValue = selectionKey(tenantSelection);
button.type = "button";
button.ariaPressed = String(selected.has(selectionKey(tenantSelection)));
button.addEventListener("click", () =>
toggleSelection(
tenantSelection,
true,
collectDescendantSelections(node, selectable),
),
);
button.append(createOrgfrontLabelText(node.name));
row.append(button);
};
const createExpandToggle = (node: OrgChartNode, hasChildren: boolean) => {
if (!hasChildren) {
const placeholder = document.createElement("span");
placeholder.className = "baron-org-picker__toggle-placeholder";
placeholder.ariaHidden = "true";
return placeholder;
}
const toggle = document.createElement("button");
toggle.className = "baron-org-picker__toggle";
toggle.dataset.baronOrgPickerToggle = selectionKey({
id: node.id,
name: node.name,
type: "tenant",
});
toggle.type = "button";
const chevron = document.createElement("span");
chevron.className = expanded.has(node.id)
? "baron-org-picker__chevron baron-org-picker__chevron--open"
: "baron-org-picker__chevron baron-org-picker__chevron--closed";
chevron.dataset.baronOrgPickerChevron = "true";
chevron.ariaHidden = "true";
toggle.append(chevron);
toggle.ariaLabel = `${node.name} ${expanded.has(node.id) ? "접기" : "펼치기"}`;
toggle.addEventListener("click", () => {
if (expanded.has(node.id)) {
expanded.delete(node.id);
} else {
expanded.add(node.id);
}
rerender();
});
return toggle;
};
const rerender = () => {
container.replaceChildren();
container.classList.add("baron-org-picker");
container.classList.toggle("baron-org-picker--orgfront", isOrgfront);
container.append(renderPickerToolbar());
const visibleRoot = filterOrgChartNode(model.root, searchQuery, selectable);
if (!visibleRoot) {
const empty = document.createElement("div");
empty.className = "baron-org-picker__empty";
empty.textContent = isOrgfront
? "검색 결과가 없습니다."
: "No matching organization or member.";
container.append(empty);
if (isOrgfront) {
container.append(renderPickerFooter());
}
return;
}
const list = document.createElement("ul");
list.className = "baron-org-picker__list";
list.append(renderPickerNode(visibleRoot));
container.append(list);
if (isOrgfront) {
container.append(renderPickerFooter());
}
};
const renderPickerToolbar = () => {
const toolbar = document.createElement("div");
toolbar.className = "baron-org-picker__toolbar";
const toolbarContent = isOrgfront ? document.createElement("div") : toolbar;
if (isOrgfront) {
toolbarContent.className = "baron-org-picker__toolbar-grid";
toolbar.append(toolbarContent);
}
const searchWrap = document.createElement("div");
searchWrap.className = "baron-org-picker__search-wrap";
if (isOrgfront) {
const searchIcon = document.createElement("span");
searchIcon.className = "baron-org-picker__search-icon";
searchIcon.dataset.baronOrgPickerSearchIcon = "true";
searchIcon.ariaHidden = "true";
searchWrap.append(searchIcon);
}
const search = document.createElement("input");
search.className = "baron-org-picker__search";
search.dataset.baronOrgPickerSearch = "true";
search.placeholder = isOrgfront
? "ID, 이름, 이메일, 메타데이터"
: "Search organization or member";
search.type = "search";
search.value = searchQuery;
search.addEventListener("input", () => {
searchQuery = search.value;
rerender();
const nextSearch = container.querySelector<HTMLInputElement>(
"[data-baron-org-picker-search]",
);
nextSearch?.focus();
nextSearch?.setSelectionRange(searchQuery.length, searchQuery.length);
});
searchWrap.append(search);
toolbarContent.append(searchWrap);
const controls = document.createElement("div");
controls.className = "baron-org-picker__controls";
if (mode === "multiple" && selectable !== "user" && showDescendantToggle) {
const descendantsLabel = document.createElement("label");
descendantsLabel.className = "baron-org-picker__descendants";
const descendants = document.createElement("input");
descendants.dataset.baronOrgPickerDescendants = "true";
descendants.type = "checkbox";
descendants.checked = includeDescendants;
const updateDescendantSelection = () => {
includeDescendants = descendants.checked;
rerender();
};
descendants.addEventListener("change", updateDescendantSelection);
descendants.addEventListener("click", updateDescendantSelection);
descendantsLabel.append(
descendants,
isOrgfront ? "하위 선택" : "Include descendants",
);
controls.append(descendantsLabel);
} else if (!isOrgfront) {
controls.append(document.createElement("span"));
}
if (!isOrgfront) {
const summary = document.createElement("span");
summary.className = "baron-org-picker__summary";
summary.dataset.baronOrgPickerSummary = "true";
summary.textContent = `${selected.size} selected`;
controls.append(summary);
if (selected.size > 0) {
const clear = document.createElement("button");
clear.className = "baron-org-picker__clear";
clear.type = "button";
clear.textContent = "Clear";
clear.addEventListener("click", () => {
selected.clear();
emitChange();
rerender();
});
controls.append(clear);
}
}
toolbarContent.append(controls);
return toolbar;
};
const renderPickerFooter = () => {
const footer = document.createElement("footer");
footer.className = "baron-org-picker__footer";
footer.dataset.baronOrgPickerFooter = "true";
const summary = document.createElement("div");
summary.className = "baron-org-picker__summary";
summary.dataset.baronOrgPickerSummary = "true";
summary.textContent =
selected.size > 0
? `${selected.size}개 항목 선택됨`
: "선택된 항목이 없습니다.";
footer.append(summary);
const actions = document.createElement("div");
actions.className = "baron-org-picker__actions";
const cancel = document.createElement("button");
cancel.className = "baron-org-picker__button";
cancel.dataset.baronOrgPickerCancel = "true";
cancel.type = "button";
cancel.textContent = "취소";
cancel.addEventListener("click", emitCancel);
actions.append(cancel);
const confirm = document.createElement("button");
confirm.className =
"baron-org-picker__button baron-org-picker__button--primary";
confirm.dataset.baronOrgPickerConfirm = "true";
confirm.disabled = selected.size === 0;
confirm.type = "button";
confirm.textContent = "선택 완료";
confirm.addEventListener("click", emitConfirm);
actions.append(confirm);
footer.append(actions);
return footer;
};
rerender();
return {
cancel: emitCancel,
confirm: emitConfirm,
destroy() {
container.replaceChildren();
container.classList.remove(
"baron-org-picker",
"baron-org-picker--orgfront",
);
selected.clear();
},
getSelection() {
return currentSelection();
},
};
}
function appendQuery(
url: URL,
key: string,
value: string | boolean | undefined,
) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
function ensureDefaultStyles() {
if (typeof document === "undefined") return;
if (document.getElementById(DEFAULT_STYLE_ID)) return;
const style = document.createElement("style");
style.id = DEFAULT_STYLE_ID;
style.dataset.baronOrgContextChartStyle = "default";
style.textContent = DEFAULT_STYLE;
document.head.append(style);
}
function renderChartNode(node: OrgChartNode): HTMLElement {
const item = document.createElement("section");
item.className = "baron-org-chart__node";
item.dataset.baronOrgNode = node.id;
const title = document.createElement("h3");
title.className = "baron-org-chart__title";
title.textContent = node.name;
item.append(title);
const meta = document.createElement("p");
meta.className = "baron-org-chart__meta";
meta.textContent = [node.type, node.orgUnitType, node.visibility]
.filter(Boolean)
.join(" · ");
item.append(meta);
if (node.members.length > 0) {
const memberList = document.createElement("ul");
memberList.className = "baron-org-chart__members";
for (const member of node.members) {
const memberItem = document.createElement("li");
memberItem.textContent = formatMember(member);
memberList.append(memberItem);
}
item.append(memberList);
}
if (node.children.length > 0) {
const children = document.createElement("div");
children.className = "baron-org-chart__children";
for (const child of node.children) {
children.append(renderChartNode(child));
}
item.append(children);
}
return item;
}
function renderMemberPickerRow(
member: OrgContextMember,
node: OrgChartNode,
mode: "single" | "multiple",
selected: Map<string, OrgPickerSelection>,
onSelect: (selection: OrgPickerSelection) => void,
isOrgfront = false,
) {
const selection: OrgPickerSelection = {
id: member.id || `${node.id}:${member.email}`,
name: member.name,
type: "user",
};
const row = document.createElement(isOrgfront ? "div" : "label");
row.className = "baron-org-picker__row baron-org-picker__row--member";
if (selected.has(selectionKey(selection))) {
row.classList.add("baron-org-picker__row--selected");
}
row.style.paddingLeft = `${node.depth * 16 + 24}px`;
if (isOrgfront && mode === "single") {
row.append(createOrgfrontMemberSpacer());
const button = document.createElement("button");
button.className = "baron-org-picker__select";
button.dataset.baronOrgPickerValue = selectionKey(selection);
button.type = "button";
button.ariaPressed = String(selected.has(selectionKey(selection)));
button.addEventListener("click", () => onSelect(selection));
button.append(createOrgfrontLabelText(member.name, member.email));
row.append(button);
return row;
}
if (isOrgfront) {
row.append(createOrgfrontMemberSpacer());
}
row.append(
createPickerInput({
mode,
selection,
selected,
onToggle: () => onSelect(selection),
}),
);
row.append(
isOrgfront
? createOrgfrontLabelText(member.name, member.email)
: createLabelText(member.name, member.email),
);
return row;
}
function createPickerInput({
mode,
selection,
selected,
onToggle,
}: {
mode: "single" | "multiple";
selection: OrgPickerSelection;
selected: Map<string, OrgPickerSelection>;
onToggle: (checked: boolean) => void;
}) {
const input = document.createElement("input");
input.type = mode === "single" ? "radio" : "checkbox";
input.name = "baron-org-picker";
input.value = selectionKey(selection);
input.checked = selected.has(selectionKey(selection));
const handleToggle = () => {
onToggle(mode === "single" ? true : !selected.has(selectionKey(selection)));
};
input.addEventListener("click", handleToggle);
return input;
}
function createLabelText(primary: string, secondary?: string) {
const text = document.createElement("span");
text.className = "baron-org-picker__label";
text.textContent = secondary ? `${primary} (${secondary})` : primary;
return text;
}
function createOrgfrontMemberSpacer() {
const spacer = document.createElement("span");
spacer.className = "baron-org-picker__toggle-placeholder";
spacer.ariaHidden = "true";
return spacer;
}
function createOrgfrontLabelText(primary: string, secondary?: string) {
const text = document.createElement("span");
text.className = "baron-org-picker__label";
const primaryText = document.createElement("span");
primaryText.className = "baron-org-picker__label-primary";
primaryText.textContent = primary;
text.append(primaryText);
if (secondary) {
const secondaryText = document.createElement("span");
secondaryText.className = "baron-org-picker__label-secondary";
secondaryText.textContent = secondary;
text.append(secondaryText);
}
return text;
}
function filterOrgChartNode(
node: OrgChartNode,
rawQuery: string,
selectable: "tenant" | "user" | "both",
): OrgChartNode | null {
const query = rawQuery.trim().toLowerCase();
if (!query) return node;
const childMatches = node.children
.map((child) => filterOrgChartNode(child, rawQuery, selectable))
.filter((child): child is OrgChartNode => Boolean(child));
const tenantMatch = orgTenantMatchesSearch(node, query, selectable);
const matchingMembers = orgMemberMatchesSearch(node, query, selectable);
if (
!tenantMatch &&
matchingMembers.length === 0 &&
childMatches.length === 0
) {
return null;
}
return {
...node,
members: tenantMatch ? node.members : matchingMembers,
children: childMatches,
};
}
function orgTenantMatchesSearch(
node: OrgChartNode,
query: string,
selectable: "tenant" | "user" | "both",
) {
const tenantValues = [
node.id,
node.name,
node.slug,
node.type,
node.orgUnitType ?? "",
node.visibility,
...node.domains,
];
if (
selectable !== "user" &&
tenantValues.some((value) => value.toLowerCase().includes(query))
) {
return true;
}
if (selectable === "tenant") {
return false;
}
return false;
}
function orgMemberMatchesSearch(
node: OrgChartNode,
query: string,
selectable: "tenant" | "user" | "both",
) {
if (selectable === "tenant") {
return [];
}
return node.members.filter((member) =>
[
member.id ?? "",
member.email,
member.name,
member.department ?? "",
member.grade ?? "",
member.position ?? "",
member.jobTitle ?? "",
].some((value) => value.toLowerCase().includes(query)),
);
}
function collectDescendantSelections(
node: OrgChartNode,
selectable: "tenant" | "user" | "both",
): OrgPickerSelection[] {
const selections: OrgPickerSelection[] = [];
const visit = (child: OrgChartNode) => {
if (selectable === "tenant" || selectable === "both") {
selections.push({ id: child.id, name: child.name, type: "tenant" });
}
if (selectable === "user" || selectable === "both") {
for (const member of child.members) {
selections.push({
id: member.id || `${child.id}:${member.email}`,
name: member.name,
type: "user",
});
}
}
for (const grandchild of child.children) {
visit(grandchild);
}
};
for (const child of node.children) {
visit(child);
}
return selections;
}
function selectionKey(selection: OrgPickerSelection) {
return `${selection.type}:${selection.id}`;
}
function formatMember(member: OrgContextMember) {
return [member.name, member.position, member.jobTitle]
.filter(Boolean)
.join(" · ");
}