1
0
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:
2026-06-04 16:06:30 +09:00
parent 6d3f128282
commit b2f155e35b
26 changed files with 1103 additions and 440 deletions

View File

@@ -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>