forked from baron/baron-sso
715 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|