forked from baron/baron-sso
네이버 웍스 연동기능 개선
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user