forked from baron/baron-sso
983 lines
34 KiB
TypeScript
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(" · ");
|
|
}
|