feat: add authenticated settings page.
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
123
shadcn-admin/src/features/tasks/components/tasks-columns.tsx
Normal file
123
shadcn-admin/src/features/tasks/components/tasks-columns.tsx
Normal 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} />,
|
||||
},
|
||||
]
|
||||
72
shadcn-admin/src/features/tasks/components/tasks-dialogs.tsx
Normal file
72
shadcn-admin/src/features/tasks/components/tasks-dialogs.tsx
Normal 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'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
197
shadcn-admin/src/features/tasks/components/tasks-table.tsx
Normal file
197
shadcn-admin/src/features/tasks/components/tasks-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user