fix: add server-side sorting to user list, prevent client-side sort on paged data (#6194)
* fix: add server-side sorting to user list, prevent client-side sort on paged data The user management table applied client-side sorting to the current page slice while pagination was handled server-side, causing ID-asc views to show pages out of order (e.g. 23-42, 3-22, 1-2). Backend: add sort_by/sort_order query params to GetAllUsers and SearchUsers with a column whitelist for safe ORDER BY generation. Frontend: pass sorting state to the API and reset to page 1 on sort change. Generic useDataTable hook now disables client-side sorted row model and sort UI for tables with manual pagination but no server-side sort handler. * fix: add id tie-breaker to non-unique sort columns, remove side effect from state updater Append secondary ORDER BY id DESC when sorting by non-unique columns (quota, created_at, etc.) to prevent row duplication/skipping across OFFSET pages. Move onPaginationChange out of setSorting updater to avoid side effects inside a pure function (React Strict Mode double-invocation safety).
This commit is contained in:
@@ -49,6 +49,7 @@ type DataTableFeatureOptions<TData> = Pick<
|
||||
| 'manualFiltering'
|
||||
| 'manualPagination'
|
||||
| 'manualSorting'
|
||||
| 'enableSorting'
|
||||
| 'enableColumnResizing'
|
||||
>
|
||||
|
||||
@@ -292,7 +293,7 @@ export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = !manualFiltering,
|
||||
withPaginationRowModel = !manualPagination,
|
||||
withSortedRowModel = !manualSorting,
|
||||
withSortedRowModel = !manualSorting && !manualPagination,
|
||||
withFacetedRowModel = !manualFiltering,
|
||||
withExpandedRowModel = false,
|
||||
} = options
|
||||
@@ -370,6 +371,11 @@ export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
(totalCount !== undefined
|
||||
? Math.ceil(totalCount / pagination.pageSize)
|
||||
: undefined)
|
||||
const resolvedEnableSorting =
|
||||
options.enableSorting ??
|
||||
(!manualPagination ||
|
||||
Boolean(options.sorting) ||
|
||||
Boolean(options.onSortingChange))
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -387,6 +393,7 @@ export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: options.enableRowSelection,
|
||||
enableSorting: resolvedEnableSorting,
|
||||
getRowId: options.getRowId,
|
||||
getSubRows: options.getSubRows,
|
||||
globalFilterFn: options.globalFilterFn,
|
||||
|
||||
@@ -172,7 +172,6 @@ export function ModelsTable() {
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
+13
-2
@@ -40,8 +40,15 @@ import type {
|
||||
export async function getUsers(
|
||||
params: GetUsersParams = {}
|
||||
): Promise<GetUsersResponse> {
|
||||
const { p = 1, page_size = 10 } = params
|
||||
const res = await api.get(`/api/user/?p=${p}&page_size=${page_size}`)
|
||||
const { p = 1, page_size = 10, sort_by, sort_order } = params
|
||||
const res = await api.get('/api/user/', {
|
||||
params: {
|
||||
p,
|
||||
page_size,
|
||||
sort_by,
|
||||
sort_order,
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -58,6 +65,8 @@ export async function searchUsers(
|
||||
status = '',
|
||||
p = 1,
|
||||
page_size = 10,
|
||||
sort_by,
|
||||
sort_order,
|
||||
} = params
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.set('keyword', keyword)
|
||||
@@ -66,6 +75,8 @@ export async function searchUsers(
|
||||
if (status) queryParams.set('status', status)
|
||||
queryParams.set('p', String(p))
|
||||
queryParams.set('page_size', String(page_size))
|
||||
if (sort_by) queryParams.set('sort_by', sort_by)
|
||||
if (sort_order) queryParams.set('sort_order', sort_order)
|
||||
const res = await api.get(`/api/user/search?${queryParams.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
+40
-1
@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import type { OnChangeFn, SortingState } from '@tanstack/react-table'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -37,13 +39,22 @@ import {
|
||||
getUserRoleOptions,
|
||||
isUserDeleted,
|
||||
} from '../constants'
|
||||
import type { User } from '../types'
|
||||
import type { User, UserSortBy } from '../types'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { useUsersColumns } from './users-columns'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/users/')
|
||||
|
||||
const USER_SORTABLE_COLUMNS = new Set<UserSortBy>([
|
||||
'id',
|
||||
'username',
|
||||
'quota',
|
||||
'group',
|
||||
'created_at',
|
||||
'last_login_at',
|
||||
])
|
||||
|
||||
function isDisabledUserRow(user: User) {
|
||||
return isUserDeleted(user) || user.status === USER_STATUS.DISABLED
|
||||
}
|
||||
@@ -53,6 +64,7 @@ export function UsersTable() {
|
||||
const columns = useUsersColumns()
|
||||
const { refreshTrigger } = useUsers()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -85,6 +97,28 @@ export function UsersTable() {
|
||||
(columnFilters.find((filter) => filter.id === 'group')?.value as string) ??
|
||||
''
|
||||
|
||||
const sortParams = useMemo(() => {
|
||||
const activeSort = sorting[0]
|
||||
if (
|
||||
!activeSort ||
|
||||
!USER_SORTABLE_COLUMNS.has(activeSort.id as UserSortBy)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
sort_by: activeSort.id as UserSortBy,
|
||||
sort_order: activeSort.desc ? 'desc' : 'asc',
|
||||
} as const
|
||||
}, [sorting])
|
||||
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
setSorting(updater)
|
||||
if (pagination.pageIndex > 0) {
|
||||
onPaginationChange({ ...pagination, pageIndex: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data with React Query
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: [
|
||||
@@ -95,6 +129,7 @@ export function UsersTable() {
|
||||
statusFilter,
|
||||
roleFilter,
|
||||
groupFilter,
|
||||
sortParams,
|
||||
refreshTrigger,
|
||||
],
|
||||
queryFn: async () => {
|
||||
@@ -104,6 +139,7 @@ export function UsersTable() {
|
||||
const params = {
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
...sortParams,
|
||||
}
|
||||
|
||||
const result =
|
||||
@@ -141,6 +177,7 @@ export function UsersTable() {
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
sorting,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
const fields = [
|
||||
@@ -157,8 +194,10 @@ export function UsersTable() {
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
onSortingChange: handleSortingChange,
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
manualSorting: true,
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
+14
@@ -78,9 +78,21 @@ export interface ApiResponse<T = unknown> {
|
||||
data?: T
|
||||
}
|
||||
|
||||
export type UserSortBy =
|
||||
| 'id'
|
||||
| 'username'
|
||||
| 'quota'
|
||||
| 'group'
|
||||
| 'created_at'
|
||||
| 'last_login_at'
|
||||
|
||||
export type UserSortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface GetUsersParams {
|
||||
p?: number
|
||||
page_size?: number
|
||||
sort_by?: UserSortBy
|
||||
sort_order?: UserSortOrder
|
||||
}
|
||||
|
||||
export interface GetUsersResponse {
|
||||
@@ -101,6 +113,8 @@ export interface SearchUsersParams {
|
||||
status?: string
|
||||
p?: number
|
||||
page_size?: number
|
||||
sort_by?: UserSortBy
|
||||
sort_order?: UserSortOrder
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
|
||||
Reference in New Issue
Block a user