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