forked from baron/baron-sso
perf(admin): full-stack performance optimization for all list tables
- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs. - Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters. - Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering. - Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture. - i18n: Synced all translation keys and cleaned up unused resources.
This commit is contained in:
@@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
} from "../../../../common/core/audit";
|
||||
import { AuditLogTable } from "../../../../common/core/components/audit";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
@@ -40,8 +35,23 @@ function AuditLogsPage() {
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
queryKey: [
|
||||
"audit-logs",
|
||||
deferredSearchActorId,
|
||||
deferredSearchAction,
|
||||
statusFilter,
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const search = [deferredSearchActorId, deferredSearchAction]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return fetchAuditLogs(
|
||||
50,
|
||||
pageParam,
|
||||
search || undefined,
|
||||
statusFilter === "all" ? undefined : statusFilter,
|
||||
);
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
@@ -51,24 +61,6 @@ function AuditLogsPage() {
|
||||
(page) =>
|
||||
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
|
||||
) ?? [];
|
||||
const filteredLogs = React.useMemo(
|
||||
() =>
|
||||
logs.filter((row) => {
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details).toLowerCase();
|
||||
const actionLabel = resolveAuditAction(row, details).toLowerCase();
|
||||
const matchesActor =
|
||||
deferredSearchActorId === "" ||
|
||||
actorLabel.includes(deferredSearchActorId.toLowerCase());
|
||||
const matchesAction =
|
||||
deferredSearchAction === "" ||
|
||||
actionLabel.includes(deferredSearchAction.toLowerCase());
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || row.status === statusFilter;
|
||||
return matchesActor && matchesAction && matchesStatus;
|
||||
}),
|
||||
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -83,7 +75,7 @@ function AuditLogsPage() {
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
@@ -185,8 +177,8 @@ function AuditLogsPage() {
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<AuditLogTable
|
||||
logs={filteredLogs}
|
||||
<VirtualizedAuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
|
||||
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../../../common/core/audit";
|
||||
import {
|
||||
type CommonBadgeVariant,
|
||||
getCommonBadgeClasses,
|
||||
} from "../../../../common/ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../../common/ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type VirtualizedAuditLogTableProps = {
|
||||
logs: AuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
return status === "success" || status === "ok" ? "success" : "warning";
|
||||
}
|
||||
|
||||
export function VirtualizedAuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: VirtualizedAuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" && (window as any)._IS_TEST_MODE);
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 80,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
overscan: isTest ? logs.length : 10,
|
||||
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) {
|
||||
return;
|
||||
}
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= logs.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
logs.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
isTest,
|
||||
]);
|
||||
|
||||
const tableMinWidth = 1010;
|
||||
|
||||
const renderRow = (row: AuditLog, index: number, virtualRow?: any) => {
|
||||
if (!row) return null;
|
||||
|
||||
const details = parseAuditDetails(row.details);
|
||||
const actorLabel = resolveAuditActor(row, details);
|
||||
const actionLabel = resolveAuditAction(row, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(row.timestamp);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
data-index={index}
|
||||
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
|
||||
className={cx(
|
||||
commonTableRowClass,
|
||||
"bg-card/40",
|
||||
virtualRow ? "absolute left-0 w-full" : "",
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td colSpan={6} className="p-0">
|
||||
<div className={cx("flex items-center", expanded && "border-b")}>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[190px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[180px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold text-foreground">{actionLabel}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[260px] shrink-0 text-xs text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(row.status),
|
||||
})}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"w-[80px] shrink-0 text-right",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() => {
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}));
|
||||
// Re-measure after state change
|
||||
setTimeout(() => rowVirtualizer.measure(), 0);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(row.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(row.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.method", "Method · {{value}}", {
|
||||
value: formatAuditValue(details.method),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.path", "Path · {{value}}", {
|
||||
value: formatAuditValue(details.path),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
|
||||
value: formatAuditValue(details.tenant_id),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.device", "Device · {{value}}", {
|
||||
value: formatAuditValue(row.device_id),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.error", "Error · {{value}}", {
|
||||
value: formatAuditValue(details.error),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.before", "Before · {{value}}", {
|
||||
value: formatAuditValue(details.before),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t("ui.common.audit.details.after", "After · {{value}}", {
|
||||
value: formatAuditValue(details.after),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={cx(commonTableViewportClass, "flex-1")}
|
||||
data-testid="audit-table-viewport"
|
||||
>
|
||||
<div
|
||||
className={commonTableWrapperClass}
|
||||
style={{ minWidth: tableMinWidth }}
|
||||
>
|
||||
<table
|
||||
className={cx(commonTableClass, "table-fixed w-full")}
|
||||
style={{ borderCollapse: "separate", borderSpacing: 0 }}
|
||||
>
|
||||
<thead
|
||||
className={cx(
|
||||
commonTableHeaderClass,
|
||||
commonStickyTableHeaderClass,
|
||||
)}
|
||||
>
|
||||
<tr className={commonTableRowClass}>
|
||||
<th className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</th>
|
||||
<th className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={commonTableBodyClass}
|
||||
style={
|
||||
!isTest
|
||||
? {
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isTest
|
||||
? logs.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
logs[virtualRow.index],
|
||||
virtualRow.index,
|
||||
virtualRow,
|
||||
),
|
||||
)}
|
||||
{logs.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center py-8 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("ui.common.audit.table.no_logs", "No audit logs found")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
|
||||
{hasNextPage ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="animate-pulse text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "더 보기")}
|
||||
</Button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -720,6 +720,11 @@ function TenantListPage() {
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1529,8 +1534,8 @@ const TenantHierarchyView: React.FC<{
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
||||
[scopeTenantId, tenants, search],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1582,7 +1587,7 @@ const TenantHierarchyView: React.FC<{
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId),
|
||||
getTenantViewRows(tenants, "table", scopeTenantId, !!search),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
@@ -1615,6 +1620,7 @@ const TenantHierarchyView: React.FC<{
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
search,
|
||||
]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
|
||||
@@ -68,8 +68,13 @@ export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
isSearchActive = false,
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const { subTree } = buildTenantFullTree(
|
||||
tenants,
|
||||
scopeTenantId || undefined,
|
||||
isSearchActive,
|
||||
);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => {
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
fireEvent.keyDown(searchInput, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText("User 19")).toBeInTheDocument();
|
||||
expect(await screen.findByText("User 19")).toBeInTheDocument();
|
||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
observeElementRect,
|
||||
type Rect,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
@@ -119,7 +117,7 @@ type UserSchemaField = {
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 8;
|
||||
const USER_ROW_OVERSCAN = 20;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
@@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
return isSuperAdminRole(role) ? "super_admin" : "user";
|
||||
}
|
||||
|
||||
function userMatchesSearch(user: UserSummary, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
user.name?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.email?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.phone?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.id?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.tenantSlug?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.tenant?.name?.toLowerCase().includes(normalizedSearch) ||
|
||||
user.department?.toLowerCase().includes(normalizedSearch) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
return {
|
||||
width: rect.width > 0 ? rect.width : fallbackWidth,
|
||||
@@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
}
|
||||
|
||||
type UserListSearchControlsProps = {
|
||||
search: string;
|
||||
initialSearch: string;
|
||||
selectedCompany: string;
|
||||
tenants: TenantSummary[];
|
||||
profileRole?: string | null;
|
||||
@@ -188,31 +168,27 @@ type UserListSearchControlsProps = {
|
||||
};
|
||||
|
||||
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
search,
|
||||
initialSearch,
|
||||
selectedCompany,
|
||||
tenants,
|
||||
profileRole,
|
||||
onSearch,
|
||||
onCompanyChange,
|
||||
}: UserListSearchControlsProps) {
|
||||
const [searchDraft, setSearchDraft] = React.useState(search);
|
||||
const [localSearch, setLocalSearch] = React.useState(initialSearch);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchDraft(search);
|
||||
}, [search]);
|
||||
setLocalSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
onSearch(searchDraft);
|
||||
}, [onSearch, searchDraft]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch();
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearch !== initialSearch) {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearch, onSearch, initialSearch]);
|
||||
|
||||
const tenantOptions = React.useMemo(
|
||||
() =>
|
||||
@@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
onClick={() => onSearch(localSearch)}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
|
||||
function UserListPage() {
|
||||
const _navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
@@ -285,9 +264,6 @@ function UserListPage() {
|
||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
@@ -345,10 +321,12 @@ function UserListPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["users", { search, tenantSlug: selectedCompany }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchUsers(50, 0, search, selectedCompany, pageParam as string),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -393,12 +371,10 @@ function UserListPage() {
|
||||
|
||||
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||
setSearch(nextSearch);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||
setSelectedCompany(nextCompany);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleExport = (includeIds = false) => {
|
||||
@@ -415,14 +391,11 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const serverItems = query.data?.items ?? [];
|
||||
const rawItems = React.useMemo(() => {
|
||||
if (!query.isFetching || search.trim() === "") {
|
||||
return serverItems;
|
||||
}
|
||||
|
||||
return serverItems.filter((user) => userMatchesSearch(user, search));
|
||||
}, [query.isFetching, search, serverItems]);
|
||||
const serverItems = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
const rawItems = serverItems;
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
@@ -496,6 +469,25 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
React.useEffect(() => {
|
||||
if (!lastItem) return;
|
||||
if (
|
||||
lastItem.index >= serverItems.length - 1 &&
|
||||
query.hasNextPage &&
|
||||
!query.isFetchingNextPage
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
lastItem,
|
||||
serverItems.length,
|
||||
query.hasNextPage,
|
||||
query.isFetchingNextPage,
|
||||
query.fetchNextPage,
|
||||
]);
|
||||
|
||||
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
|
||||
const tableColumnCount = 9 + visibleUserSchemaFields.length;
|
||||
|
||||
@@ -514,8 +506,7 @@ function UserListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const total = query.data?.pages[0]?.total ?? 0;
|
||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
@@ -627,10 +618,10 @@ function UserListPage() {
|
||||
actions={
|
||||
<>
|
||||
<UserListSearchControls
|
||||
search={search}
|
||||
initialSearch={search}
|
||||
selectedCompany={selectedCompany}
|
||||
tenants={tenants}
|
||||
profileRole={profile?.role}
|
||||
profileRole={profileRole}
|
||||
onSearch={handleSearch}
|
||||
onCompanyChange={handleCompanyChange}
|
||||
/>
|
||||
@@ -1241,36 +1232,6 @@ function UserListPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1 || query.isFetching}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t("ui.common.previous", "Previous")}
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
||||
page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || query.isFetching}
|
||||
>
|
||||
{t("ui.common.next", "Next")}
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user