Просмотр исходного кода

feat: improve tables and sync table states with search param (#199)

* refactor: use columns directly in data-table

Use columns directly in the data-table to avoid
unnecessary prop drilling.

* refactor: rename data-table to tasks-table

* fix: replace column filtering with global filtering in tasks-table

* feat: sync task table states with search param

* refactor: extract table synced state logic into custom hook

* refactor: improve tasks rotue search param type safety

* feat: sync user table states with search param

* feat: update search and navigation route API in users-table

Closes #184
Sat Naing 11 месяцев назад
Родитель
Сommit
081311160c

+ 9 - 7
src/features/tasks/components/data-table-toolbar.tsx

@@ -13,17 +13,16 @@ type DataTableToolbarProps<TData> = {
 export function DataTableToolbar<TData>({
   table,
 }: DataTableToolbarProps<TData>) {
-  const isFiltered = table.getState().columnFilters.length > 0
+  const isFiltered =
+    table.getState().columnFilters.length > 0 || table.getState().globalFilter
 
   return (
     <div className='flex items-center justify-between'>
       <div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
         <Input
-          placeholder='Filter tasks...'
-          value={(table.getColumn('title')?.getFilterValue() as string) ?? ''}
-          onChange={(event) =>
-            table.getColumn('title')?.setFilterValue(event.target.value)
-          }
+          placeholder='Filter by title or ID...'
+          value={table.getState().globalFilter ?? ''}
+          onChange={(event) => table.setGlobalFilter(event.target.value)}
           className='h-8 w-[150px] lg:w-[250px]'
         />
         <div className='flex gap-x-2'>
@@ -45,7 +44,10 @@ export function DataTableToolbar<TData>({
         {isFiltered && (
           <Button
             variant='ghost'
-            onClick={() => table.resetColumnFilters()}
+            onClick={() => {
+              table.resetColumnFilters()
+              table.setGlobalFilter('')
+            }}
             className='h-8 px-2 lg:px-3'
           >
             Reset

+ 51 - 18
src/features/tasks/components/data-table.tsx → src/features/tasks/components/tasks-table.tsx

@@ -1,7 +1,6 @@
-import * as React from 'react'
+import { useEffect, useState } from 'react'
+import { getRouteApi } from '@tanstack/react-router'
 import {
-  type ColumnDef,
-  type ColumnFiltersState,
   type SortingState,
   type VisibilityState,
   flexRender,
@@ -13,6 +12,7 @@ import {
   getSortedRowModel,
   useReactTable,
 } from '@tanstack/react-table'
+import { useTableUrlState } from '@/hooks/use-table-url-state'
 import {
   Table,
   TableBody,
@@ -21,26 +21,43 @@ import {
   TableHeader,
   TableRow,
 } from '@/components/ui/table'
+import { type Task } from '../data/schema'
 import { DataTableBulkActions } from './data-table-bulk-actions'
 import { DataTablePagination } from './data-table-pagination'
 import { DataTableToolbar } from './data-table-toolbar'
+import { tasksColumns as columns } from './tasks-columns'
 
-type DataTableProps<TData, TValue> = {
-  columns: ColumnDef<TData, TValue>[]
-  data: TData[]
+const route = getRouteApi('/_authenticated/tasks/')
+
+type DataTableProps = {
+  data: Task[]
 }
 
-export function TasksTable<TData, TValue>({
-  columns,
-  data,
-}: DataTableProps<TData, TValue>) {
-  const [rowSelection, setRowSelection] = React.useState({})
-  const [columnVisibility, setColumnVisibility] =
-    React.useState<VisibilityState>({})
-  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
-    []
-  )
-  const [sorting, setSorting] = React.useState<SortingState>([])
+export function TasksTable({ data }: DataTableProps) {
+  // Local UI-only states
+  const [rowSelection, setRowSelection] = useState({})
+  const [sorting, setSorting] = useState<SortingState>([])
+  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+
+  // Synced with URL states
+  const {
+    globalFilter,
+    onGlobalFilterChange,
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  } = useTableUrlState({
+    search: route.useSearch(),
+    navigate: route.useNavigate(),
+    pagination: { defaultPage: 1, defaultPageSize: 10 },
+    globalFilter: { enabled: true, key: 'filter' },
+    columnFilters: [
+      { columnId: 'status', searchKey: 'status', type: 'array' },
+      { columnId: 'priority', searchKey: 'priority', type: 'array' },
+    ],
+  })
 
   const table = useReactTable({
     data,
@@ -50,20 +67,36 @@ export function TasksTable<TData, TValue>({
       columnVisibility,
       rowSelection,
       columnFilters,
+      globalFilter,
+      pagination,
     },
     enableRowSelection: true,
     onRowSelectionChange: setRowSelection,
     onSortingChange: setSorting,
-    onColumnFiltersChange: setColumnFilters,
     onColumnVisibilityChange: setColumnVisibility,
+    globalFilterFn: (row, _columnId, filterValue) => {
+      const id = String(row.getValue('id')).toLowerCase()
+      const title = String(row.getValue('title')).toLowerCase()
+      const searchValue = String(filterValue).toLowerCase()
+
+      return id.includes(searchValue) || title.includes(searchValue)
+    },
     getCoreRowModel: getCoreRowModel(),
     getFilteredRowModel: getFilteredRowModel(),
     getPaginationRowModel: getPaginationRowModel(),
     getSortedRowModel: getSortedRowModel(),
     getFacetedRowModel: getFacetedRowModel(),
     getFacetedUniqueValues: getFacetedUniqueValues(),
+    onPaginationChange,
+    onGlobalFilterChange,
+    onColumnFiltersChange,
   })
 
+  const pageCount = table.getPageCount()
+  useEffect(() => {
+    ensurePageInRange(pageCount)
+  }, [pageCount, ensurePageInRange])
+
   return (
     <div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
       <DataTableToolbar table={table} />

+ 9 - 9
src/features/tasks/data/data.tsx

@@ -27,28 +27,28 @@ export const labels = [
 
 export const statuses = [
   {
-    value: 'backlog',
     label: 'Backlog',
+    value: 'backlog' as const,
     icon: HelpCircle,
   },
   {
-    value: 'todo',
     label: 'Todo',
+    value: 'todo' as const,
     icon: Circle,
   },
   {
-    value: 'in progress',
     label: 'In Progress',
+    value: 'in progress' as const,
     icon: Timer,
   },
   {
-    value: 'done',
     label: 'Done',
+    value: 'done' as const,
     icon: CheckCircle,
   },
   {
-    value: 'canceled',
     label: 'Canceled',
+    value: 'canceled' as const,
     icon: CircleOff,
   },
 ]
@@ -56,22 +56,22 @@ export const statuses = [
 export const priorities = [
   {
     label: 'Low',
-    value: 'low',
+    value: 'low' as const,
     icon: ArrowDown,
   },
   {
     label: 'Medium',
-    value: 'medium',
+    value: 'medium' as const,
     icon: ArrowRight,
   },
   {
     label: 'High',
-    value: 'high',
+    value: 'high' as const,
     icon: ArrowUp,
   },
   {
     label: 'Critical',
-    value: 'critical',
+    value: 'critical' as const,
     icon: AlertCircle,
   },
 ]

+ 2 - 3
src/features/tasks/index.tsx

@@ -4,11 +4,10 @@ import { Main } from '@/components/layout/main'
 import { ProfileDropdown } from '@/components/profile-dropdown'
 import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
-import { TasksTable } from './components/data-table'
-import { tasksColumns } from './components/tasks-columns'
 import { TasksDialogs } from './components/tasks-dialogs'
 import { TasksPrimaryButtons } from './components/tasks-primary-buttons'
 import { TasksProvider } from './components/tasks-provider'
+import { TasksTable } from './components/tasks-table'
 import { tasks } from './data/tasks'
 
 export function Tasks() {
@@ -34,7 +33,7 @@ export function Tasks() {
           <TasksPrimaryButtons />
         </div>
         <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
-          <TasksTable data={tasks} columns={tasksColumns} />
+          <TasksTable data={tasks} />
         </div>
       </Main>
 

+ 34 - 9
src/features/users/components/users-table.tsx

@@ -1,7 +1,5 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
 import {
-  type ColumnDef,
-  type ColumnFiltersState,
   type SortingState,
   type VisibilityState,
   flexRender,
@@ -14,6 +12,7 @@ import {
   useReactTable,
 } from '@tanstack/react-table'
 import { cn } from '@/lib/utils'
+import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
 import {
   Table,
   TableBody,
@@ -26,6 +25,7 @@ import { type User } from '../data/schema'
 import { DataTableBulkActions } from './data-table-bulk-actions'
 import { DataTablePagination } from './data-table-pagination'
 import { DataTableToolbar } from './data-table-toolbar'
+import { usersColumns as columns } from './users-columns'
 
 declare module '@tanstack/react-table' {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -35,38 +35,63 @@ declare module '@tanstack/react-table' {
 }
 
 type DataTableProps = {
-  columns: ColumnDef<User>[]
   data: User[]
+  search: Record<string, unknown>
+  navigate: NavigateFn
 }
 
-export function UsersTable({ columns, data }: DataTableProps) {
+export function UsersTable({ data, search, navigate }: DataTableProps) {
   const [rowSelection, setRowSelection] = useState({})
   const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
-  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
   const [sorting, setSorting] = useState<SortingState>([])
 
+  const {
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  } = useTableUrlState({
+    search,
+    navigate,
+    pagination: { defaultPage: 1, defaultPageSize: 10 },
+    globalFilter: { enabled: false },
+    columnFilters: [
+      // username per-column text filter
+      { columnId: 'username', searchKey: 'username', type: 'string' },
+      { columnId: 'status', searchKey: 'status', type: 'array' },
+      { columnId: 'role', searchKey: 'role', type: 'array' },
+    ],
+  })
+
   const table = useReactTable({
     data,
     columns,
     state: {
       sorting,
-      columnVisibility,
+      pagination,
       rowSelection,
       columnFilters,
+      columnVisibility,
     },
     enableRowSelection: true,
+    onPaginationChange,
+    onColumnFiltersChange,
     onRowSelectionChange: setRowSelection,
     onSortingChange: setSorting,
-    onColumnFiltersChange: setColumnFilters,
     onColumnVisibilityChange: setColumnVisibility,
+    getPaginationRowModel: getPaginationRowModel(),
     getCoreRowModel: getCoreRowModel(),
     getFilteredRowModel: getFilteredRowModel(),
-    getPaginationRowModel: getPaginationRowModel(),
     getSortedRowModel: getSortedRowModel(),
     getFacetedRowModel: getFacetedRowModel(),
     getFacetedUniqueValues: getFacetedUniqueValues(),
   })
 
+  useEffect(() => {
+    ensurePageInRange(table.getPageCount())
+  }, [table, ensurePageInRange])
+
   return (
     <div className='space-y-4 max-sm:has-[div[role="toolbar"]]:mb-16'>
       <DataTableToolbar table={table} />

+ 8 - 2
src/features/users/index.tsx

@@ -1,9 +1,9 @@
+import { getRouteApi } from '@tanstack/react-router'
 import { Header } from '@/components/layout/header'
 import { Main } from '@/components/layout/main'
 import { ProfileDropdown } from '@/components/profile-dropdown'
 import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
-import { usersColumns } from './components/users-columns'
 import { UsersDialogs } from './components/users-dialogs'
 import { UsersPrimaryButtons } from './components/users-primary-buttons'
 import { UsersProvider } from './components/users-provider'
@@ -11,6 +11,8 @@ import { UsersTable } from './components/users-table'
 import { userListSchema } from './data/schema'
 import { users } from './data/users'
 
+const route = getRouteApi('/_authenticated/users/')
+
 export function Users() {
   // Parse user list
   const userList = userListSchema.parse(users)
@@ -36,7 +38,11 @@ export function Users() {
           <UsersPrimaryButtons />
         </div>
         <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
-          <UsersTable data={userList} columns={usersColumns} />
+          <UsersTable
+            data={userList}
+            search={route.useSearch()}
+            navigate={route.useNavigate()}
+          />
         </div>
       </Main>
 

+ 219 - 0
src/hooks/use-table-url-state.ts

@@ -0,0 +1,219 @@
+import { useMemo, useState } from 'react'
+import type {
+  ColumnFiltersState,
+  OnChangeFn,
+  PaginationState,
+} from '@tanstack/react-table'
+
+type SearchRecord = Record<string, unknown>
+
+export type NavigateFn = (opts: {
+  search:
+    | true
+    | SearchRecord
+    | ((prev: SearchRecord) => Partial<SearchRecord> | SearchRecord)
+  replace?: boolean
+}) => void
+
+type UseTableUrlStateParams = {
+  search: SearchRecord
+  navigate: NavigateFn
+  pagination?: {
+    pageKey?: string
+    pageSizeKey?: string
+    defaultPage?: number
+    defaultPageSize?: number
+  }
+  globalFilter?: {
+    enabled?: boolean
+    key?: string
+    trim?: boolean
+  }
+  columnFilters?: Array<
+    | {
+        columnId: string
+        searchKey: string
+        type?: 'string'
+        // Optional transformers for custom types
+        serialize?: (value: unknown) => unknown
+        deserialize?: (value: unknown) => unknown
+      }
+    | {
+        columnId: string
+        searchKey: string
+        type: 'array'
+        serialize?: (value: unknown) => unknown
+        deserialize?: (value: unknown) => unknown
+      }
+  >
+}
+
+type UseTableUrlStateReturn = {
+  // Global filter
+  globalFilter?: string
+  onGlobalFilterChange?: OnChangeFn<string>
+  // Column filters
+  columnFilters: ColumnFiltersState
+  onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
+  // Pagination
+  pagination: PaginationState
+  onPaginationChange: OnChangeFn<PaginationState>
+  // Helpers
+  ensurePageInRange: (
+    pageCount: number,
+    opts?: { resetTo?: 'first' | 'last' }
+  ) => void
+}
+
+export function useTableUrlState(
+  params: UseTableUrlStateParams
+): UseTableUrlStateReturn {
+  const {
+    search,
+    navigate,
+    pagination: paginationCfg,
+    globalFilter: globalFilterCfg,
+    columnFilters: columnFiltersCfg = [],
+  } = params
+
+  const pageKey = paginationCfg?.pageKey ?? ('page' as string)
+  const pageSizeKey = paginationCfg?.pageSizeKey ?? ('pageSize' as string)
+  const defaultPage = paginationCfg?.defaultPage ?? 1
+  const defaultPageSize = paginationCfg?.defaultPageSize ?? 10
+
+  const globalFilterKey = globalFilterCfg?.key ?? ('filter' as string)
+  const globalFilterEnabled = globalFilterCfg?.enabled ?? true
+  const trimGlobal = globalFilterCfg?.trim ?? true
+
+  // Build initial column filters from the current search params
+  const initialColumnFilters: ColumnFiltersState = useMemo(() => {
+    const collected: ColumnFiltersState = []
+    for (const cfg of columnFiltersCfg) {
+      const raw = (search as SearchRecord)[cfg.searchKey]
+      const deserialize = cfg.deserialize ?? ((v: unknown) => v)
+      if (cfg.type === 'string') {
+        const value = (deserialize(raw) as string) ?? ''
+        if (typeof value === 'string' && value.trim() !== '') {
+          collected.push({ id: cfg.columnId, value })
+        }
+      } else {
+        // default to array type
+        const value = (deserialize(raw) as unknown[]) ?? []
+        if (Array.isArray(value) && value.length > 0) {
+          collected.push({ id: cfg.columnId, value })
+        }
+      }
+    }
+    return collected
+  }, [columnFiltersCfg, search])
+
+  const [columnFilters, setColumnFilters] =
+    useState<ColumnFiltersState>(initialColumnFilters)
+
+  const pagination: PaginationState = useMemo(() => {
+    const rawPage = (search as SearchRecord)[pageKey]
+    const rawPageSize = (search as SearchRecord)[pageSizeKey]
+    const pageNum = typeof rawPage === 'number' ? rawPage : defaultPage
+    const pageSizeNum =
+      typeof rawPageSize === 'number' ? rawPageSize : defaultPageSize
+    return { pageIndex: Math.max(0, pageNum - 1), pageSize: pageSizeNum }
+  }, [search, pageKey, pageSizeKey, defaultPage, defaultPageSize])
+
+  const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
+    const next = typeof updater === 'function' ? updater(pagination) : updater
+    const nextPage = next.pageIndex + 1
+    const nextPageSize = next.pageSize
+    navigate({
+      search: (prev) => ({
+        ...(prev as SearchRecord),
+        [pageKey]: nextPage <= defaultPage ? undefined : nextPage,
+        [pageSizeKey]:
+          nextPageSize === defaultPageSize ? undefined : nextPageSize,
+      }),
+    })
+  }
+
+  const [globalFilter, setGlobalFilter] = useState<string | undefined>(() => {
+    if (!globalFilterEnabled) return undefined
+    const raw = (search as SearchRecord)[globalFilterKey]
+    return typeof raw === 'string' ? raw : ''
+  })
+
+  const onGlobalFilterChange: OnChangeFn<string> | undefined =
+    globalFilterEnabled
+      ? (updater) => {
+          const next =
+            typeof updater === 'function'
+              ? updater(globalFilter ?? '')
+              : updater
+          const value = trimGlobal ? next.trim() : next
+          setGlobalFilter(value)
+          navigate({
+            search: (prev) => ({
+              ...(prev as SearchRecord),
+              [pageKey]: undefined,
+              [globalFilterKey]: value ? value : undefined,
+            }),
+          })
+        }
+      : undefined
+
+  const onColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
+    const next =
+      typeof updater === 'function' ? updater(columnFilters) : updater
+    setColumnFilters(next)
+
+    const patch: Record<string, unknown> = {}
+
+    for (const cfg of columnFiltersCfg) {
+      const found = next.find((f) => f.id === cfg.columnId)
+      const serialize = cfg.serialize ?? ((v: unknown) => v)
+      if (cfg.type === 'string') {
+        const value =
+          typeof found?.value === 'string' ? (found.value as string) : ''
+        patch[cfg.searchKey] =
+          value.trim() !== '' ? serialize(value) : undefined
+      } else {
+        const value = Array.isArray(found?.value)
+          ? (found!.value as unknown[])
+          : []
+        patch[cfg.searchKey] = value.length > 0 ? serialize(value) : undefined
+      }
+    }
+
+    navigate({
+      search: (prev) => ({
+        ...(prev as SearchRecord),
+        [pageKey]: undefined,
+        ...patch,
+      }),
+    })
+  }
+
+  const ensurePageInRange = (
+    pageCount: number,
+    opts: { resetTo?: 'first' | 'last' } = { resetTo: 'first' }
+  ) => {
+    const currentPage = (search as SearchRecord)[pageKey]
+    const pageNum = typeof currentPage === 'number' ? currentPage : defaultPage
+    if (pageCount > 0 && pageNum > pageCount) {
+      navigate({
+        replace: true,
+        search: (prev) => ({
+          ...(prev as SearchRecord),
+          [pageKey]: opts.resetTo === 'last' ? pageCount : undefined,
+        }),
+      })
+    }
+  }
+
+  return {
+    globalFilter: globalFilterEnabled ? (globalFilter ?? '') : undefined,
+    onGlobalFilterChange,
+    columnFilters,
+    onColumnFiltersChange,
+    pagination,
+    onPaginationChange,
+    ensurePageInRange,
+  }
+}

+ 17 - 0
src/routes/_authenticated/tasks/index.tsx

@@ -1,6 +1,23 @@
+import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
 import { Tasks } from '@/features/tasks'
+import { priorities, statuses } from '@/features/tasks/data/data'
+
+const taskSearchSchema = z.object({
+  page: z.number().optional().catch(1),
+  pageSize: z.number().optional().catch(10),
+  status: z
+    .array(z.enum(statuses.map((status) => status.value)))
+    .optional()
+    .catch([]),
+  priority: z
+    .array(z.enum(priorities.map((priority) => priority.value)))
+    .optional()
+    .catch([]),
+  filter: z.string().optional().catch(''),
+})
 
 export const Route = createFileRoute('/_authenticated/tasks/')({
+  validateSearch: taskSearchSchema,
   component: Tasks,
 })

+ 26 - 0
src/routes/_authenticated/users/index.tsx

@@ -1,6 +1,32 @@
+import z from 'zod'
 import { createFileRoute } from '@tanstack/react-router'
 import { Users } from '@/features/users'
+import { roles } from '@/features/users/data/data'
+
+const usersSearchSchema = z.object({
+  page: z.number().optional().catch(1),
+  pageSize: z.number().optional().catch(10),
+  // Facet filters
+  status: z
+    .array(
+      z.union([
+        z.literal('active'),
+        z.literal('inactive'),
+        z.literal('invited'),
+        z.literal('suspended'),
+      ])
+    )
+    .optional()
+    .catch([]),
+  role: z
+    .array(z.enum(roles.map((r) => r.value as (typeof roles)[number]['value'])))
+    .optional()
+    .catch([]),
+  // Per-column text filter (example for username)
+  username: z.string().optional().catch(''),
+})
 
 export const Route = createFileRoute('/_authenticated/users/')({
+  validateSearch: usersSearchSchema,
   component: Users,
 })

+ 4 - 2
src/routes/clerk/_authenticated/user-management.tsx

@@ -14,7 +14,6 @@ import { Main } from '@/components/layout/main'
 import { LearnMore } from '@/components/learn-more'
 import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
-import { usersColumns } from '@/features/users/components/users-columns'
 import { UsersDialogs } from '@/features/users/components/users-dialogs'
 import { UsersPrimaryButtons } from '@/features/users/components/users-primary-buttons'
 import { UsersProvider } from '@/features/users/components/users-provider'
@@ -27,6 +26,9 @@ export const Route = createFileRoute('/clerk/_authenticated/user-management')({
 })
 
 function UserManagement() {
+  const search = Route.useSearch()
+  const navigate = Route.useNavigate()
+
   const [opened, setOpened] = useState(true)
   const { isLoaded, isSignedIn } = useAuth()
 
@@ -90,7 +92,7 @@ function UserManagement() {
               <UsersPrimaryButtons />
             </div>
             <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-y-0 lg:space-x-12'>
-              <UsersTable data={userList} columns={usersColumns} />
+              <UsersTable data={userList} navigate={navigate} search={search} />
             </div>
           </Main>