forked from baron/baron-sso
perf(admin): implement server-side search and virtualization for tenant list
- Backend: Added 'search' parameter to TenantRepository and TenantService. - Backend: Updated all Tenant list calls to support searching. - Backend: Enhanced UserRepository.List to support cursor-based pagination and search. - Frontend: Switched TenantListPage to use useInfiniteQuery for lazy loading. - Frontend: Implemented list virtualization in TenantHierarchyView using @tanstack/react-virtual. - Frontend: Added server-side search with debouncing (useDeferredValue). - Fixed various Go compilation errors caused by method signature changes.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -93,6 +94,7 @@ import {
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
@@ -115,7 +117,6 @@ import {
|
||||
resolveTenantSelectionIds,
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
@@ -264,7 +265,6 @@ function resolveImportParentSelection(
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||
@@ -304,6 +304,8 @@ function TenantListPage() {
|
||||
(d: TenantImportDetail) => d.action === importResultFilter,
|
||||
);
|
||||
}, [importResult, importResultFilter]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const debouncedSearch = React.useDeferredValue(search.trim());
|
||||
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
|
||||
const _tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -314,18 +316,18 @@ function TenantListPage() {
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy"],
|
||||
queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchTenants(
|
||||
tenantPageSize,
|
||||
0,
|
||||
undefined,
|
||||
pageParam ? pageParam : undefined,
|
||||
scopeTenantId || undefined,
|
||||
pageParam ? (pageParam as string) : undefined,
|
||||
debouncedSearch,
|
||||
),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||
enabled: profileRole === "super_admin",
|
||||
});
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
@@ -436,6 +438,11 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const rawTenants = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -443,15 +450,7 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantPages = React.useMemo(
|
||||
() => query.data?.pages ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
const rawTenants = React.useMemo(
|
||||
() => tenantPages.flatMap((page) => page.items),
|
||||
[tenantPages],
|
||||
);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const tenantTotal = query.data?.pages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
@@ -924,6 +923,10 @@ function TenantListPage() {
|
||||
getSortIcon={getSortIcon}
|
||||
viewMode={viewMode}
|
||||
scopeTenantId={scopeTenantId}
|
||||
fetchNextPage={query.fetchNextPage}
|
||||
hasNextPage={!!query.hasNextPage}
|
||||
isFetchingNextPage={query.isFetchingNextPage}
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1500,6 +1503,10 @@ const TenantHierarchyView: React.FC<{
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
viewMode: TenantViewMode;
|
||||
scopeTenantId: string;
|
||||
fetchNextPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isLoading: boolean;
|
||||
}> = ({
|
||||
tenants,
|
||||
selectedIds,
|
||||
@@ -1514,7 +1521,13 @@ const TenantHierarchyView: React.FC<{
|
||||
getSortIcon,
|
||||
viewMode,
|
||||
scopeTenantId,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
}) => {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
@@ -1569,51 +1582,26 @@ const TenantHierarchyView: React.FC<{
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
||||
tenantMatchesListSearch(tenant, search),
|
||||
),
|
||||
getTenantViewRows(tenants, "table", scopeTenantId),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
}
|
||||
|
||||
const result: TenantViewRow[] = [];
|
||||
const term = search.toLowerCase().trim();
|
||||
|
||||
// When searching, we show matched nodes and all their ancestors.
|
||||
const matchedIds = new Set<string>();
|
||||
if (term) {
|
||||
const findMatches = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (tenantMatchesListSearch(node, term)) {
|
||||
matchedIds.add(node.id);
|
||||
}
|
||||
if (node.children) findMatches(node.children);
|
||||
}
|
||||
};
|
||||
findMatches(subTree);
|
||||
}
|
||||
|
||||
const collect = (nodes: TenantNode[], depth: number) => {
|
||||
// Sort nodes at the current depth
|
||||
const sortedNodes = sortItems(nodes, sortConfig, tenantSortResolvers);
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
// If searching, show node if it matches OR any of its descendants match.
|
||||
const hasMatchingDescendant = (n: TenantNode): boolean => {
|
||||
if (matchedIds.has(n.id)) return true;
|
||||
return n.children.some(hasMatchingDescendant);
|
||||
};
|
||||
|
||||
if (!term || hasMatchingDescendant(node)) {
|
||||
result.push({ ...node, depth });
|
||||
if (
|
||||
(term || expandedIds.has(node.id)) &&
|
||||
node.children &&
|
||||
node.children.length > 0
|
||||
) {
|
||||
collect(node.children, depth + 1);
|
||||
}
|
||||
result.push({ ...node, depth });
|
||||
if (
|
||||
expandedIds.has(node.id) &&
|
||||
node.children &&
|
||||
node.children.length > 0
|
||||
) {
|
||||
collect(node.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1622,7 +1610,6 @@ const TenantHierarchyView: React.FC<{
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
search,
|
||||
sortConfig,
|
||||
subTree,
|
||||
tenantSortResolvers,
|
||||
@@ -1630,6 +1617,34 @@ const TenantHierarchyView: React.FC<{
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flattenedRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => _tenantEstimatedRowHeight,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
React.useEffect(() => {
|
||||
const lastItem = virtualRows[virtualRows.length - 1];
|
||||
if (!lastItem) return;
|
||||
|
||||
if (
|
||||
lastItem.index >= flattenedRows.length - 1 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
virtualRows,
|
||||
flattenedRows.length,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
const visibleSelectableIds = React.useMemo(
|
||||
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
||||
[deletableTenants],
|
||||
@@ -1640,11 +1655,15 @@ const TenantHierarchyView: React.FC<{
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table className="min-w-[1180px]">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="flex-1 overflow-auto relative custom-scrollbar"
|
||||
data-testid="tenant-table-container"
|
||||
>
|
||||
<Table className="min-w-[1180px] relative border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap">
|
||||
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
@@ -1718,8 +1737,14 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flattenedRows.length === 0 && (
|
||||
<TableBody className="relative">
|
||||
{rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && (
|
||||
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{flattenedRows.length === 0 && !isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
@@ -1732,7 +1757,10 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{flattenedRows.map((node) => {
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const node = flattenedRows[virtualRow.index];
|
||||
const isSelected = selectedIds.includes(node.id);
|
||||
const hasChildren =
|
||||
viewMode === "tree" &&
|
||||
node.children &&
|
||||
@@ -1744,16 +1772,16 @@ const TenantHierarchyView: React.FC<{
|
||||
return (
|
||||
<TableRow
|
||||
key={node.id}
|
||||
className={
|
||||
selectedIds.includes(node.id) ? "bg-primary/5" : ""
|
||||
}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className={cn(isSelected ? "bg-primary/5" : "", "h-[73px]")}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="text-center px-4">
|
||||
{isSeedTenant(node) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(node.id)}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onSelect(node, !!checked)}
|
||||
/>
|
||||
)}
|
||||
@@ -1761,27 +1789,34 @@ const TenantHierarchyView: React.FC<{
|
||||
<TableCell className="font-semibold p-0">
|
||||
<div
|
||||
className="flex items-center h-full min-h-[3rem] py-1"
|
||||
style={{ paddingLeft: `${node.depth * 28 + 12}px` }}
|
||||
style={{
|
||||
paddingLeft:
|
||||
viewMode === "tree"
|
||||
? `${node.depth * 28 + 12}px`
|
||||
: "12px",
|
||||
}}
|
||||
>
|
||||
<div className="w-5 flex items-center justify-center mr-1.5 shrink-0">
|
||||
{hasChildren && !search ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(node.id)}
|
||||
className="p-0.5 hover:bg-black/5 rounded cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
node.depth > 0 && (
|
||||
<div className="w-1 h-1 rounded-full bg-border" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "tree" && (
|
||||
<div className="w-5 flex items-center justify-center mr-1.5 shrink-0">
|
||||
{hasChildren && !search ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(node.id)}
|
||||
className="p-0.5 hover:bg-black/5 rounded cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
node.depth > 0 && (
|
||||
<div className="w-1 h-1 rounded-full bg-border" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypeIcon
|
||||
size={14}
|
||||
@@ -1857,6 +1892,27 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{rowVirtualizer.getTotalSize() > 0 && virtualRows.length > 0 && (
|
||||
<tr
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
|
||||
}}
|
||||
>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
{t("msg.common.loading_more", "Loading more...")}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user