1
0
forked from baron/baron-sso
Files
baron-sso/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx

715 lines
22 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import * as React from "react";
import { useLocation } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
import {
type OrgPickerEmbedOptions,
type OrgPickerMode,
type OrgPickerResult,
type OrgPickerSelectableType,
type OrgPickerSelection,
type OrgPickerTreeNode,
buildOrgPickerEmbedSrc,
nodeKey,
parseOrgPickerEmbedOptions,
parseOrgPickerMode,
parseOrgPickerSelectableType,
} from "../pickerTypes";
function canSelectNode(
node: OrgPickerTreeNode,
select: OrgPickerSelectableType,
) {
return select === "both" || select === node.type;
}
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
return {
type: node.type,
id: node.id,
name: node.name,
};
}
function collectSelectedNodes({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const selected = new Map<string, OrgPickerTreeNode>();
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && canSelectNode(node, select)) {
selected.set(key, node);
if (includeDescendants && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
selected.set(nodeKey(descendant), descendant);
}
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return Array.from(selected.values()).map(toSelection);
}
function collectCheckedKeys({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const checkedKeys = new Set(selectedKeys);
if (!includeDescendants) return checkedKeys;
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
checkedKeys.add(nodeKey(descendant));
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return checkedKeys;
}
function postPickerMessage(message: unknown) {
window.parent.postMessage(message, "*");
}
function collectSearchValues(value: unknown, depth = 0): string[] {
if (value == null || depth > 4) return [];
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return [String(value)];
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectSearchValues(item, depth + 1));
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>).flatMap(
([key, item]) => [key, ...collectSearchValues(item, depth + 1)],
);
}
return [];
}
function getNodeSearchValues(node: OrgPickerTreeNode) {
const tenantSearchValues = node.tenant
? collectSearchValues({
id: node.tenant.id,
type: node.tenant.type,
name: node.tenant.name,
slug: node.tenant.slug,
description: node.tenant.description,
status: node.tenant.status,
domains: node.tenant.domains,
parentId: node.tenant.parentId,
config: node.tenant.config,
memberCount: node.tenant.memberCount,
createdAt: node.tenant.createdAt,
updatedAt: node.tenant.updatedAt,
})
: [];
return [
node.type,
node.id,
node.name,
node.parentId ?? "",
...tenantSearchValues,
...collectSearchValues(node.user),
].map((value) => value.toLowerCase());
}
function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) {
return getNodeSearchValues(node).some((value) => value.includes(query));
}
function filterPickerTree(
roots: OrgPickerTreeNode[],
rawQuery: string,
): OrgPickerTreeNode[] {
const query = rawQuery.trim().toLowerCase();
if (!query) return roots;
const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => {
const filteredChildren = node.children
.map(filterNode)
.filter((child): child is OrgPickerTreeNode => Boolean(child));
if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
return null;
};
return roots
.map(filterNode)
.filter((node): node is OrgPickerTreeNode => Boolean(node));
}
function OrgPickerTree({
roots,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
}: {
roots: OrgPickerTreeNode[];
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
}) {
return (
<div className="space-y-1" data-testid="org-picker-tree">
{roots.map((node) => (
<OrgPickerTreeItem
key={nodeKey(node)}
mode={mode}
node={node}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
);
}
function OrgPickerTreeItem({
node,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
depth = 0,
}: {
node: OrgPickerTreeNode;
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
depth?: number;
}) {
const [isOpen, setIsOpen] = React.useState(true);
const selectable = canSelectNode(node, select);
const hasChildren = node.children.length > 0;
const key = nodeKey(node);
const checked = selectedKeys.has(key);
const label = `${node.name} 선택`;
const email = node.type === "user" ? node.user?.email : undefined;
const nameTestId =
node.type === "tenant"
? "org-picker-node-name-tenant"
: "org-picker-node-name-user";
const content = (
<span className="flex min-w-0 flex-col">
<span
className={`truncate font-semibold leading-5 ${
node.type === "tenant" ? "text-[#0a2114]" : ""
}`}
data-testid={nameTestId}
>
{node.name}
</span>
{email ? (
<span className="truncate text-xs leading-5 text-muted-foreground">
{email}
</span>
) : null}
</span>
);
return (
<div className="relative">
<div
className={`group flex min-h-7 items-center gap-1.5 rounded-sm py-0.5 pr-1.5 transition ${
mode === "single" && checked
? "bg-primary/15 text-foreground ring-2 ring-primary/60 shadow-sm"
: "hover:bg-secondary/50"
} ${depth > 0 ? "pl-4" : "pl-1"}`}
data-selected={mode === "single" && checked ? "true" : undefined}
>
{hasChildren ? (
<button
type="button"
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition hover:bg-secondary"
onClick={() => setIsOpen((current) => !current)}
aria-label={`${node.name} ${isOpen ? "접기" : "펼치기"}`}
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
)}
{mode === "multiple" && selectable ? (
<input
aria-label={label}
checked={checked}
className="h-3.5 w-3.5 rounded border-border"
onChange={(event) => onToggle(node, event.target.checked)}
type="checkbox"
/>
) : null}
{mode === "single" && selectable ? (
<button
type="button"
aria-pressed={checked}
className={`min-w-0 flex-1 rounded-sm px-1 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring ${
checked ? "text-primary" : ""
}`}
data-selected={checked ? "true" : undefined}
onClick={() => onSingleSelect(node)}
>
{content}
</button>
) : (
<div className="min-w-0 flex-1">{content}</div>
)}
</div>
{isOpen && hasChildren ? (
<div className="ml-4">
{node.children.map((child) => (
<OrgPickerTreeItem
depth={depth + 1}
key={nodeKey(child)}
mode={mode}
node={child}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
) : null}
</div>
);
}
export function OrgPickerEmbedPage() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const mode = parseOrgPickerMode(searchParams.get("mode"));
const select = parseOrgPickerSelectableType(searchParams.get("select"));
const rootTenantId = searchParams.get("rootTenantId") || undefined;
const tenantId =
searchParams.get("tenantSlug") ||
searchParams.get("tenantId") ||
searchParams.get("companyTenantId") ||
undefined;
const [includeDescendants, setIncludeDescendants] = React.useState(
searchParams.get("includeDescendants") !== "false",
);
const showDescendantToggle =
searchParams.get("showDescendantToggle") !== "false";
const [searchQuery, setSearchQuery] = React.useState("");
const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(
() => new Set(),
);
const tenantsQuery = useQuery({
queryKey: ["org-picker-tenants"],
queryFn: () => fetchTenants(10000, 0),
});
const usersQuery = useQuery({
queryKey: ["org-picker-users"],
queryFn: () => fetchUsers(5000, 0),
});
React.useEffect(() => {
postPickerMessage({ type: "orgfront:picker:ready" });
}, []);
const tree = React.useMemo(() => {
return buildOrgPickerTree({
tenants: tenantsQuery.data?.items ?? [],
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
rootTenantId,
tenantId,
});
}, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]);
const selectedItems = React.useMemo(
() =>
collectSelectedNodes({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const checkedKeys = React.useMemo(
() =>
collectCheckedKeys({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const filteredRoots = React.useMemo(
() => filterPickerTree(tree.roots, searchQuery),
[searchQuery, tree.roots],
);
const handleSingleSelect = (node: OrgPickerTreeNode) => {
setSelectedKeys(new Set([nodeKey(node)]));
};
const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => {
setSelectedKeys((current) => {
const next = new Set(current);
const key = nodeKey(node);
if (checked) next.add(key);
else next.delete(key);
return next;
});
};
const confirmSelection = () => {
const payload: OrgPickerResult = {
mode,
selections: selectedItems,
};
postPickerMessage({ type: "orgfront:picker:confirm", payload });
};
const cancelSelection = () => {
postPickerMessage({ type: "orgfront:picker:cancel" });
};
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
const isError = tenantsQuery.isError || usersQuery.isError;
React.useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow;
const bodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = htmlOverflow;
document.body.style.overflow = bodyOverflow;
};
}, []);
React.useEffect(() => {
if (!isError) return;
postPickerMessage({
type: "orgfront:picker:error",
error: "org_picker_load_failed",
});
}, [isError]);
if (isLoading) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-muted-foreground">
...
</div>
);
}
if (isError) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-destructive">
.
</div>
);
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
<main className="flex min-h-0 flex-1 flex-col">
<div
className="shrink-0 border-b border-border bg-background p-2"
data-testid="org-picker-search-section"
>
<div className="grid grid-cols-[minmax(0,1fr),auto] items-end gap-2">
<div>
<label className="sr-only" htmlFor="org-picker-search">
/
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
/>
<input
id="org-picker-search"
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="ID, 이름, 이메일, 메타데이터"
type="search"
value={searchQuery}
/>
</div>
</div>
{mode === "multiple" && showDescendantToggle ? (
<label
className="inline-flex h-9 items-center gap-2 whitespace-nowrap text-sm"
data-testid="org-picker-descendant-toggle"
>
<input
checked={includeDescendants}
className="h-3.5 w-3.5"
onChange={(event) =>
setIncludeDescendants(event.target.checked)
}
type="checkbox"
/>
<span> </span>
</label>
) : null}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto p-3"
data-testid="org-picker-tree-scroll"
>
{filteredRoots.length > 0 ? (
<OrgPickerTree
mode={mode}
onSingleSelect={handleSingleSelect}
onToggle={handleToggle}
roots={filteredRoots}
select={select}
selectedKeys={checkedKeys}
/>
) : (
<div className="grid min-h-40 place-items-center rounded-md border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
</main>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-background px-3 py-2">
<div className="min-w-0 text-sm text-muted-foreground">
{selectedItems.length > 0
? `${selectedItems.length}개 항목 선택됨`
: "선택된 항목이 없습니다."}
</div>
<div className="flex items-center gap-2">
<Button onClick={cancelSelection} type="button" variant="outline">
<X size={16} />
</Button>
<Button
disabled={selectedItems.length === 0}
onClick={confirmSelection}
type="button"
>
<Check size={16} />
</Button>
</div>
</footer>
</div>
);
}
export function OrgPickerPage() {
const location = useLocation();
const shareToken = new URLSearchParams(location.search).get("token");
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const pickerSrcBase = buildOrgPickerEmbedSrc(options);
const pickerSrc = shareToken
? `${pickerSrcBase}&token=${encodeURIComponent(shareToken)}`
: pickerSrcBase;
return (
<div className="space-y-4">
<header className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Picker Workbench
</p>
<h1 className="text-2xl font-semibold"> </h1>
</div>
<div className="rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
{pickerSrc}
</div>
</header>
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.mode}
onChange={(event) =>
setOptions((current) => ({
...current,
mode: event.target.value as OrgPickerMode,
}))
}
>
<option value="multiple"> </option>
<option value="single"> </option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.select}
onChange={(event) =>
setOptions((current) => ({
...current,
select: event.target.value as OrgPickerSelectableType,
}))
}
>
<option value="both">&</option>
<option value="tenant"></option>
<option value="user"></option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
setOptions((current) => ({
...current,
tenantId: event.target.value,
}))
}
placeholder="saman"
type="text"
value={options.tenantId}
/>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeDescendants}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
includeDescendants: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.showDescendantToggle}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
showDescendantToggle: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
width: Number.parseInt(event.target.value || "400", 10),
}))
}
type="number"
value={options.width}
/>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
height: Number.parseInt(event.target.value || "600", 10),
}))
}
type="number"
value={options.height}
/>
</label>
</section>
<div
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
style={{
width: options.width,
height: options.height,
}}
>
<iframe
className="h-full w-full bg-background"
src={pickerSrc}
title="조직 선택기 테스트"
/>
</div>
</div>
);
}