1
0
forked from baron/baron-sso

네이버 웍스 연동기능 개선

This commit is contained in:
2026-05-18 15:36:30 +09:00
parent c71ece84b8
commit e29d056b9e
61 changed files with 4137 additions and 710 deletions

View File

@@ -9,7 +9,7 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"build:org-context-chart": "npm run build:org-context-chart:full && npm run build:org-context-chart:min",
"build:org-context-chart": "node scripts/build-org-context-chart.mjs",
"build:org-context-chart:full": "vite build --config vite.org-context-chart.config.ts",
"build:org-context-chart:min": "ORG_CONTEXT_CHART_MINIFY=true vite build --config vite.org-context-chart.config.ts",
"lint": "biome check .",

View File

@@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
const buildId = createBuildId();
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const env = {
...process.env,
ORG_CONTEXT_CHART_BUILD_ID: buildId,
};
for (const script of [
"build:org-context-chart:full",
"build:org-context-chart:min",
]) {
const result = spawnSync(npmCommand, ["run", script], {
env,
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function createBuildId() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const month = String(now.getMonth() + 1).padStart(2, "0");
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
return `${year}${month}${random}`;
}

View File

@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
fi
ensure_frontend_dependencies() {
# If common workspace exists, manage dependencies from there
if [ -d /common ] && [ -f /common/package.json ]; then
WORKSPACE_DIR="/common"
LOCK_FILE="/common/pnpm-lock.yaml"
APP_WORKSPACE_FILTER="../orgfront"
# If common workspace exists, manage dependencies from the real workspace tree.
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
WORKSPACE_DIR="/workspace/common"
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
if [ "$WORKSPACE_DIR" = "/common" ]; then
(cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi

View File

@@ -85,19 +85,101 @@ export type OrgPickerSelection = {
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;
@@ -202,6 +284,7 @@ export function renderOrgChart(
container: HTMLElement,
model: OrgChartModel,
): { destroy: () => void } {
ensureDefaultStyles();
container.replaceChildren();
container.classList.add("baron-org-chart");
const root = document.createElement("div");
@@ -221,13 +304,24 @@ export function renderOrgPicker(
model: OrgChartModel,
options: OrgPickerOptions = {},
): OrgPickerController {
if (options.injectStyles !== false) {
ensureDefaultStyles();
}
const mode = options.mode ?? "single";
const selectable = options.selectable ?? "tenant";
const includeDescendants = options.includeDescendants ?? false;
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 = Array.from(selected.values());
const selection = currentSelection();
options.onChange?.(selection);
container.dispatchEvent(
new CustomEvent("baron-org-picker-change", {
@@ -237,6 +331,26 @@ export function renderOrgPicker(
);
};
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,
@@ -270,17 +384,82 @@ export function renderOrgPicker(
const renderPickerNode = (node: OrgChartNode): HTMLElement => {
const item = document.createElement("li");
item.className = "baron-org-picker__item";
const row = document.createElement("label");
row.className = "baron-org-picker__row";
row.style.paddingLeft = `${node.depth * 16}px`;
const hasChildren = node.children.length > 0;
const tenantSelection: OrgPickerSelection = {
id: node.id,
name: node.name,
type: "tenant",
};
if (selectable === "tenant" || selectable === "both") {
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,
@@ -294,50 +473,231 @@ export function renderOrgPicker(
),
}),
);
}
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)), []),
),
);
}
row.append(createOrgfrontLabelText(node.name));
return;
}
if (node.children.length > 0) {
const children = document.createElement("ul");
children.className = "baron-org-picker__children";
for (const child of node.children) {
children.append(renderPickerNode(child));
}
item.append(children);
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;
}
return item;
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(model.root));
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");
container.classList.remove(
"baron-org-picker",
"baron-org-picker--orgfront",
);
selected.clear();
},
getSelection() {
return Array.from(selected.values());
return currentSelection();
},
};
}
@@ -356,6 +716,16 @@ 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";
@@ -401,15 +771,35 @@ function renderMemberPickerRow(
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("label");
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,
@@ -418,7 +808,11 @@ function renderMemberPickerRow(
onToggle: () => onSelect(selection),
}),
);
row.append(createLabelText(member.name, member.email));
row.append(
isOrgfront
? createOrgfrontLabelText(member.name, member.email)
: createLabelText(member.name, member.email),
);
return row;
}
@@ -452,6 +846,103 @@ function createLabelText(primary: string, secondary?: string) {
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",

View File

@@ -168,4 +168,179 @@ describe("org-context chart SDK", () => {
},
]);
});
it("packages default picker UX and styles with search and descendant selection", () => {
const model = buildOrgChartModel(sampleOrgContext);
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
const picker = renderOrgPicker(pickerContainer, model, {
mode: "multiple",
selectable: "both",
onChange,
});
expect(
document.head.querySelector(
'style[data-baron-org-context-chart-style="default"]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="search"][data-baron-org-picker-search]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="checkbox"][data-baron-org-picker-descendants]',
),
).not.toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-summary]")
?.textContent,
).toContain("0 selected");
const search = pickerContainer.querySelector<HTMLInputElement>(
'input[type="search"][data-baron-org-picker-search]',
);
expect(search).not.toBeNull();
if (!search) return;
search.value = "platform";
search.dispatchEvent(new Event("input", { bubbles: true }));
expect(pickerContainer.textContent).toContain("Platform");
expect(pickerContainer.textContent).not.toContain(
"Leader (leader@example.com)",
);
search.value = "";
search.dispatchEvent(new Event("input", { bubbles: true }));
const descendantToggle = pickerContainer.querySelector<HTMLInputElement>(
'input[type="checkbox"][data-baron-org-picker-descendants]',
);
expect(descendantToggle).not.toBeNull();
descendantToggle?.click();
const companyBaron = pickerContainer.querySelector<HTMLInputElement>(
'input[value="tenant:company-baron"]',
);
expect(companyBaron).not.toBeNull();
companyBaron?.click();
expect(picker.getSelection()).toEqual([
{ id: "company-baron", name: "Baron", type: "tenant" },
{ id: "team-platform", name: "Platform", type: "tenant" },
{
id: "team-platform:engineer@example.com",
name: "Engineer",
type: "user",
},
]);
expect(
pickerContainer.querySelector("[data-baron-org-picker-summary]")
?.textContent,
).toContain("3 selected");
expect(onChange).toHaveBeenLastCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
{ id: "team-platform", name: "Platform", type: "tenant" },
{
id: "team-platform:engineer@example.com",
name: "Engineer",
type: "user",
},
]);
});
it("renders the orgfront-compatible picker UX", () => {
const model = buildOrgChartModel(sampleOrgContext);
const pickerContainer = document.createElement("div");
const onChange = vi.fn();
const onConfirm = vi.fn();
const onCancel = vi.fn();
const picker = renderOrgPicker(pickerContainer, model, {
mode: "single",
selectable: "tenant",
variant: "orgfront",
showDescendantToggle: false,
onCancel,
onChange,
onConfirm,
});
expect(
pickerContainer.classList.contains("baron-org-picker--orgfront"),
).toBe(true);
expect(
pickerContainer.querySelector<HTMLInputElement>(
'input[type="radio"][value="tenant:company-baron"]',
),
).toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-search-icon]"),
).not.toBeNull();
expect(
pickerContainer.querySelector<HTMLInputElement>(
"[data-baron-org-picker-search]",
)?.placeholder,
).toBe("ID, 이름, 이메일, 메타데이터");
expect(
pickerContainer.querySelector("[data-baron-org-picker-descendants]"),
).toBeNull();
expect(
pickerContainer.querySelector("[data-baron-org-picker-footer]"),
).not.toBeNull();
const companyButton = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-value="tenant:company-baron"]',
);
expect(companyButton).not.toBeNull();
companyButton?.click();
expect(onChange).toHaveBeenCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
expect(picker.getSelection()).toEqual([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
const collapse = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
);
expect(collapse).not.toBeNull();
expect(collapse?.textContent).toBe("");
expect(
collapse?.querySelector("[data-baron-org-picker-chevron]"),
).not.toBeNull();
collapse?.click();
const collapsed = pickerContainer.querySelector<HTMLButtonElement>(
'button[data-baron-org-picker-toggle="tenant:company-baron"]',
);
expect(
collapsed
?.querySelector("[data-baron-org-picker-chevron]")
?.classList.contains("baron-org-picker__chevron--open"),
).toBe(false);
expect(
pickerContainer.querySelector(
'button[data-baron-org-picker-value="tenant:team-platform"]',
),
).toBeNull();
const confirm = pickerContainer.querySelector<HTMLButtonElement>(
"[data-baron-org-picker-confirm]",
);
expect(confirm?.disabled).toBe(false);
confirm?.click();
expect(onConfirm).toHaveBeenCalledWith([
{ id: "company-baron", name: "Baron", type: "tenant" },
]);
pickerContainer
.querySelector<HTMLButtonElement>("[data-baron-org-picker-cancel]")
?.click();
expect(onCancel).toHaveBeenCalled();
picker.destroy();
});
});

View File

@@ -6,7 +6,8 @@ const config: Config = {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/**/*.{ts,tsx,css}",
"../common/core/**/*.{ts,tsx}",
"../common/shell/**/*.{ts,tsx}",
],
};

View File

@@ -2,7 +2,17 @@ import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const isMinifiedBuild = process.env.ORG_CONTEXT_CHART_MINIFY === "true";
const buildId = process.env.ORG_CONTEXT_CHART_BUILD_ID ?? createBuildId();
const fileSuffix = isMinifiedBuild ? ".min" : "";
const fileBaseName = `boc-${buildId}${fileSuffix}`;
function createBuildId() {
const now = new Date();
const year = String(now.getFullYear()).slice(-2);
const month = String(now.getMonth() + 1).padStart(2, "0");
const random = String(Math.floor(Math.random() * 10000)).padStart(4, "0");
return `${year}${month}${random}`;
}
export default defineConfig({
build: {
@@ -12,9 +22,7 @@ export default defineConfig({
new URL("./src/sdk/org-context-chart/index.ts", import.meta.url),
),
fileName: (format) =>
format === "es"
? `baron-org-context-chart${fileSuffix}.js`
: `baron-org-context-chart${fileSuffix}.umd.cjs`,
format === "es" ? `${fileBaseName}.js` : `${fileBaseName}.umd.cjs`,
formats: ["es", "umd"],
name: "BaronOrgContextChart",
},