feat: add authenticated settings page.

This commit is contained in:
liqupan
2026-02-02 20:12:19 +08:00
parent cb3e16cd16
commit 6c32d845a7
259 changed files with 24685 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { Trash2, CircleArrowUp, ArrowUpDown, Download } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { TasksMultiDeleteDialog } from './tasks-multi-delete-dialog'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleBulkStatusChange = (status: string) => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Updating status...',
success: () => {
table.resetRowSelection()
return `Status updated to "${status}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
},
error: 'Error',
})
table.resetRowSelection()
}
const handleBulkPriorityChange = (priority: string) => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Updating priority...',
success: () => {
table.resetRowSelection()
return `Priority updated to "${priority}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
},
error: 'Error',
})
table.resetRowSelection()
}
const handleBulkExport = () => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Exporting tasks...',
success: () => {
table.resetRowSelection()
return `Exported ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''} to CSV.`
},
error: 'Error',
})
table.resetRowSelection()
}
return (
<>
<BulkActionsToolbar table={table} entityName='task'>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='icon'
className='size-8'
aria-label='Update status'
title='Update status'
>
<CircleArrowUp />
<span className='sr-only'>Update status</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Update status</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent sideOffset={14}>
{statuses.map((status) => (
<DropdownMenuItem
key={status.value}
defaultValue={status.value}
onClick={() => handleBulkStatusChange(status.value)}
>
{status.icon && (
<status.icon className='size-4 text-muted-foreground' />
)}
{status.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='icon'
className='size-8'
aria-label='Update priority'
title='Update priority'
>
<ArrowUpDown />
<span className='sr-only'>Update priority</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Update priority</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent sideOffset={14}>
{priorities.map((priority) => (
<DropdownMenuItem
key={priority.value}
defaultValue={priority.value}
onClick={() => handleBulkPriorityChange(priority.value)}
>
{priority.icon && (
<priority.icon className='size-4 text-muted-foreground' />
)}
{priority.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkExport()}
className='size-8'
aria-label='Export tasks'
title='Export tasks'
>
<Download />
<span className='sr-only'>Export tasks</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Export tasks</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label='Delete selected tasks'
title='Delete selected tasks'
>
<Trash2 />
<span className='sr-only'>Delete selected tasks</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete selected tasks</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<TasksMultiDeleteDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
table={table}
/>
</>
)
}

View File

@@ -0,0 +1,83 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { type Row } from '@tanstack/react-table'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { labels } from '../data/data'
import { taskSchema } from '../data/schema'
import { useTasks } from './tasks-provider'
type DataTableRowActionsProps<TData> = {
row: Row<TData>
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const task = taskSchema.parse(row.original)
const { setOpen, setCurrentRow } = useTasks()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(task)
setOpen('update')
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem disabled>Make a copy</DropdownMenuItem>
<DropdownMenuItem disabled>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={task.label}>
{labels.map((label) => (
<DropdownMenuRadioItem key={label.value} value={label.value}>
{label.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(task)
setOpen('delete')
}}
>
Delete
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,123 @@
import { type ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { labels, priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { DataTableRowActions } from './data-table-row-actions'
export const tasksColumns: ColumnDef<Task>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Task' />
),
cell: ({ row }) => <div className='w-[80px]'>{row.getValue('id')}</div>,
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'title',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Title' />
),
meta: {
className: 'ps-1 max-w-0 w-2/3',
tdClassName: 'ps-4',
},
cell: ({ row }) => {
const label = labels.find((label) => label.value === row.original.label)
return (
<div className='flex space-x-2'>
{label && <Badge variant='outline'>{label.label}</Badge>}
<span className='truncate font-medium'>{row.getValue('title')}</span>
</div>
)
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Status' />
),
meta: { className: 'ps-1', tdClassName: 'ps-4' },
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue('status')
)
if (!status) {
return null
}
return (
<div className='flex w-[100px] items-center gap-2'>
{status.icon && (
<status.icon className='size-4 text-muted-foreground' />
)}
<span>{status.label}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
accessorKey: 'priority',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Priority' />
),
meta: { className: 'ps-1', tdClassName: 'ps-3' },
cell: ({ row }) => {
const priority = priorities.find(
(priority) => priority.value === row.getValue('priority')
)
if (!priority) {
return null
}
return (
<div className='flex items-center gap-2'>
{priority.icon && (
<priority.icon className='size-4 text-muted-foreground' />
)}
<span>{priority.label}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />,
},
]

View File

@@ -0,0 +1,72 @@
import { showSubmittedData } from '@/lib/show-submitted-data'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TasksImportDialog } from './tasks-import-dialog'
import { TasksMutateDrawer } from './tasks-mutate-drawer'
import { useTasks } from './tasks-provider'
export function TasksDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useTasks()
return (
<>
<TasksMutateDrawer
key='task-create'
open={open === 'create'}
onOpenChange={() => setOpen('create')}
/>
<TasksImportDialog
key='tasks-import'
open={open === 'import'}
onOpenChange={() => setOpen('import')}
/>
{currentRow && (
<>
<TasksMutateDrawer
key={`task-update-${currentRow.id}`}
open={open === 'update'}
onOpenChange={() => {
setOpen('update')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<ConfirmDialog
key='task-delete'
destructive
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
handleConfirm={() => {
setOpen(null)
setTimeout(() => {
setCurrentRow(null)
}, 500)
showSubmittedData(
currentRow,
'The following task has been deleted:'
)
}}
className='max-w-md'
title={`Delete this task: ${currentRow.id} ?`}
desc={
<>
You are about to delete a task with the ID{' '}
<strong>{currentRow.id}</strong>. <br />
This action cannot be undone.
</>
}
confirmText='Delete'
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,110 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
file: z
.instanceof(FileList)
.refine((files) => files.length > 0, {
message: 'Please upload a file',
})
.refine(
(files) => ['text/csv'].includes(files?.[0]?.type),
'Please upload csv format.'
),
})
type TaskImportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function TasksImportDialog({
open,
onOpenChange,
}: TaskImportDialogProps) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { file: undefined },
})
const fileRef = form.register('file')
const onSubmit = () => {
const file = form.getValues('file')
if (file && file[0]) {
const fileDetails = {
name: file[0].name,
size: file[0].size,
type: file[0].type,
}
showSubmittedData(fileDetails, 'You have imported the following file:')
}
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
onOpenChange(val)
form.reset()
}}
>
<DialogContent className='gap-2 sm:max-w-sm'>
<DialogHeader className='text-start'>
<DialogTitle>Import Tasks</DialogTitle>
<DialogDescription>
Import tasks quickly from a CSV file.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form id='task-import-form' onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='file'
render={() => (
<FormItem className='my-2'>
<FormLabel>File</FormLabel>
<FormControl>
<Input type='file' {...fileRef} className='h-8 py-0' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className='gap-2'>
<DialogClose asChild>
<Button variant='outline'>Close</Button>
</DialogClose>
<Button type='submit' form='task-import-form'>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
type TaskMultiDeleteDialogProps<TData> = {
open: boolean
onOpenChange: (open: boolean) => void
table: Table<TData>
}
const CONFIRM_WORD = 'DELETE'
export function TasksMultiDeleteDialog<TData>({
open,
onOpenChange,
table,
}: TaskMultiDeleteDialogProps<TData>) {
const [value, setValue] = useState('')
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleDelete = () => {
if (value.trim() !== CONFIRM_WORD) {
toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
return
}
onOpenChange(false)
toast.promise(sleep(2000), {
loading: 'Deleting tasks...',
success: () => {
setValue('')
table.resetRowSelection()
return `Deleted ${selectedRows.length} ${
selectedRows.length > 1 ? 'tasks' : 'task'
}`
},
error: 'Error',
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== CONFIRM_WORD}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete {selectedRows.length}{' '}
{selectedRows.length > 1 ? 'tasks' : 'task'}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete the selected tasks? <br />
This action cannot be undone.
</p>
<Label className='my-4 flex flex-col items-start gap-1.5'>
<span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,212 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { SelectDropdown } from '@/components/select-dropdown'
import { type Task } from '../data/schema'
type TaskMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: Task
}
const formSchema = z.object({
title: z.string().min(1, 'Title is required.'),
status: z.string().min(1, 'Please select a status.'),
label: z.string().min(1, 'Please select a label.'),
priority: z.string().min(1, 'Please choose a priority.'),
})
type TaskForm = z.infer<typeof formSchema>
export function TasksMutateDrawer({
open,
onOpenChange,
currentRow,
}: TaskMutateDrawerProps) {
const isUpdate = !!currentRow
const form = useForm<TaskForm>({
resolver: zodResolver(formSchema),
defaultValues: currentRow ?? {
title: '',
status: '',
label: '',
priority: '',
},
})
const onSubmit = (data: TaskForm) => {
// do something with the form data
onOpenChange(false)
form.reset()
showSubmittedData(data)
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
form.reset()
}}
>
<SheetContent className='flex flex-col'>
<SheetHeader className='text-start'>
<SheetTitle>{isUpdate ? 'Update' : 'Create'} Task</SheetTitle>
<SheetDescription>
{isUpdate
? 'Update the task by providing necessary info.'
: 'Add a new task by providing necessary info.'}
Click save when you&apos;re done.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='tasks-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-6 overflow-y-auto px-4'
>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} placeholder='Enter a title' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select dropdown'
items={[
{ label: 'In Progress', value: 'in progress' },
{ label: 'Backlog', value: 'backlog' },
{ label: 'Todo', value: 'todo' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Done', value: 'done' },
]}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='label'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Label</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='documentation' />
</FormControl>
<FormLabel className='font-normal'>
Documentation
</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='feature' />
</FormControl>
<FormLabel className='font-normal'>Feature</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='bug' />
</FormControl>
<FormLabel className='font-normal'>Bug</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='priority'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Priority</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='high' />
</FormControl>
<FormLabel className='font-normal'>High</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='medium' />
</FormControl>
<FormLabel className='font-normal'>Medium</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='low' />
</FormControl>
<FormLabel className='font-normal'>Low</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<SheetFooter className='gap-2'>
<SheetClose asChild>
<Button variant='outline'>Close</Button>
</SheetClose>
<Button form='tasks-form' type='submit'>
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,21 @@
import { Download, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTasks } from './tasks-provider'
export function TasksPrimaryButtons() {
const { setOpen } = useTasks()
return (
<div className='flex gap-2'>
<Button
variant='outline'
className='space-x-1'
onClick={() => setOpen('import')}
>
<span>Import</span> <Download size={18} />
</Button>
<Button className='space-x-1' onClick={() => setOpen('create')}>
<span>Create</span> <Plus size={18} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { type Task } from '../data/schema'
type TasksDialogType = 'create' | 'update' | 'delete' | 'import'
type TasksContextType = {
open: TasksDialogType | null
setOpen: (str: TasksDialogType | null) => void
currentRow: Task | null
setCurrentRow: React.Dispatch<React.SetStateAction<Task | null>>
}
const TasksContext = React.createContext<TasksContextType | null>(null)
export function TasksProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<TasksDialogType>(null)
const [currentRow, setCurrentRow] = useState<Task | null>(null)
return (
<TasksContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</TasksContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTasks = () => {
const tasksContext = React.useContext(TasksContext)
if (!tasksContext) {
throw new Error('useTasks has to be used within <TasksContext>')
}
return tasksContext
}

View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
import { priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { tasksColumns as columns } from './tasks-columns'
const route = getRouteApi('/_authenticated/tasks/')
type DataTableProps = {
data: Task[]
}
export function TasksTable({ data }: DataTableProps) {
// Local UI-only states
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
// Local state management for table (uncomment to use local-only state, not synced with URL)
// const [globalFilter, onGlobalFilterChange] = useState('')
// const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
// const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
// Synced with URL states (updated to match route search schema defaults)
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' },
],
})
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
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={cn(
'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
'flex flex-1 flex-col gap-4'
)}
>
<DataTableToolbar
table={table}
searchPlaceholder='Filter by title or ID...'
filters={[
{
columnId: 'status',
title: 'Status',
options: statuses,
},
{
columnId: 'priority',
title: 'Priority',
options: priorities,
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table className='min-w-xl'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
header.column.columnDef.meta?.className,
header.column.columnDef.meta?.thClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
cell.column.columnDef.meta?.className,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} className='mt-auto' />
<DataTableBulkActions table={table} />
</div>
)
}