Parcourir la source

feat: implement task dialogs

Add create, edit, delete, and import dialogs.
satnaing il y a 1 an
Parent
commit
f45225822d

+ 23 - 5
src/features/tasks/components/data-table-row-actions.tsx

@@ -1,5 +1,6 @@
 import { DotsHorizontalIcon } from '@radix-ui/react-icons'
 import { Row } from '@tanstack/react-table'
+import { IconTrash } from '@tabler/icons-react'
 import {
   DropdownMenu,
   DropdownMenuContent,
@@ -14,6 +15,7 @@ import {
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
 import { Button } from '@/components/button'
+import { useTasksContext } from '../context/tasks-context'
 import { labels } from '../data/data'
 import { taskSchema } from '../data/schema'
 
@@ -26,6 +28,8 @@ export function DataTableRowActions<TData>({
 }: DataTableRowActionsProps<TData>) {
   const task = taskSchema.parse(row.original)
 
+  const { setOpen, setCurrentRow } = useTasksContext()
+
   return (
     <DropdownMenu modal={false}>
       <DropdownMenuTrigger asChild>
@@ -38,9 +42,16 @@ export function DataTableRowActions<TData>({
         </Button>
       </DropdownMenuTrigger>
       <DropdownMenuContent align='end' className='w-[160px]'>
-        <DropdownMenuItem>Edit</DropdownMenuItem>
-        <DropdownMenuItem>Make a copy</DropdownMenuItem>
-        <DropdownMenuItem>Favorite</DropdownMenuItem>
+        <DropdownMenuItem
+          onClick={() => {
+            setCurrentRow(task)
+            setOpen('update')
+          }}
+        >
+          Edit
+        </DropdownMenuItem>
+        <DropdownMenuItem disabled>Make a copy</DropdownMenuItem>
+        <DropdownMenuItem disabled>Favorite</DropdownMenuItem>
         <DropdownMenuSeparator />
         <DropdownMenuSub>
           <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
@@ -55,9 +66,16 @@ export function DataTableRowActions<TData>({
           </DropdownMenuSubContent>
         </DropdownMenuSub>
         <DropdownMenuSeparator />
-        <DropdownMenuItem>
+        <DropdownMenuItem
+          onClick={() => {
+            setCurrentRow(task)
+            setOpen('delete')
+          }}
+        >
           Delete
-          <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+          <DropdownMenuShortcut>
+            <IconTrash size={16} />
+          </DropdownMenuShortcut>
         </DropdownMenuItem>
       </DropdownMenuContent>
     </DropdownMenu>

+ 118 - 0
src/features/tasks/components/tasks-import-dialog.tsx

@@ -0,0 +1,118 @@
+import { useEffect } from 'react'
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from '@/hooks/use-toast'
+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.'
+    ),
+})
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}
+
+export function TasksImportDialog({ open, onOpenChange }: Props) {
+  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,
+      }
+      toast({
+        title: 'You have imported the following file:',
+        description: (
+          <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+            <code className='text-white'>
+              {JSON.stringify(fileDetails, null, 2)}
+            </code>
+          </pre>
+        ),
+      })
+    }
+    onOpenChange(false)
+  }
+
+  /* Reset form when modal is closed */
+  useEffect(() => {
+    if (!open) {
+      form.reset()
+    }
+  }, [form, open])
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className='sm:max-w-sm gap-2'>
+        <DialogHeader>
+          <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='space-y-1 mb-2'>
+                  <FormLabel>File</FormLabel>
+                  <FormControl>
+                    <Input type='file' {...fileRef} className='h-8' />
+                  </FormControl>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+          </form>
+        </Form>
+        <DialogFooter>
+          <DialogClose asChild>
+            <Button variant='outline'>Close</Button>
+          </DialogClose>
+          <Button type='submit' form='task-import-form'>
+            Import
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 215 - 0
src/features/tasks/components/tasks-mutate-drawer.tsx

@@ -0,0 +1,215 @@
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from '@/hooks/use-toast'
+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 { Task } from '../data/schema'
+
+interface Props {
+  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 TasksForm = z.infer<typeof formSchema>
+
+export function TasksMutateDrawer({ open, onOpenChange, currentRow }: Props) {
+  const isUpdate = !!currentRow
+
+  const form = useForm<TasksForm>({
+    resolver: zodResolver(formSchema),
+    defaultValues: currentRow ?? {
+      title: '',
+      status: '',
+      label: '',
+      priority: '',
+    },
+  })
+
+  const onSubmit = (data: TasksForm) => {
+    // do something with the form data
+    onOpenChange(false)
+    form.reset()
+    toast({
+      title: 'You submitted the following values:',
+      description: (
+        <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+          <code className='text-white'>{JSON.stringify(data, null, 2)}</code>
+        </pre>
+      ),
+    })
+  }
+
+  return (
+    <Sheet
+      open={open}
+      onOpenChange={(v) => {
+        onOpenChange(v)
+        form.reset()
+      }}
+    >
+      <SheetContent className='flex flex-col'>
+        <SheetHeader>
+          <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='space-y-5 flex-1'
+          >
+            <FormField
+              control={form.control}
+              name='title'
+              render={({ field }) => (
+                <FormItem className='space-y-1'>
+                  <FormLabel>Title</FormLabel>
+                  <FormControl>
+                    <Input {...field} placeholder='Enter a title' />
+                  </FormControl>
+                  <FormMessage />
+                </FormItem>
+              )}
+            />
+            <FormField
+              control={form.control}
+              name='status'
+              render={({ field }) => (
+                <FormItem className='space-y-1'>
+                  <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='space-y-3 relative'>
+                  <FormLabel>Label</FormLabel>
+                  <FormControl>
+                    <RadioGroup
+                      onValueChange={field.onChange}
+                      defaultValue={field.value}
+                      className='flex flex-col space-y-1'
+                    >
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <FormControl>
+                          <RadioGroupItem value='documentation' />
+                        </FormControl>
+                        <FormLabel className='font-normal'>
+                          Documentation
+                        </FormLabel>
+                      </FormItem>
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <FormControl>
+                          <RadioGroupItem value='feature' />
+                        </FormControl>
+                        <FormLabel className='font-normal'>Feature</FormLabel>
+                      </FormItem>
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <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='space-y-3 relative'>
+                  <FormLabel>Priority</FormLabel>
+                  <FormControl>
+                    <RadioGroup
+                      onValueChange={field.onChange}
+                      defaultValue={field.value}
+                      className='flex flex-col space-y-1'
+                    >
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <FormControl>
+                          <RadioGroupItem value='high' />
+                        </FormControl>
+                        <FormLabel className='font-normal'>High</FormLabel>
+                      </FormItem>
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <FormControl>
+                          <RadioGroupItem value='medium' />
+                        </FormControl>
+                        <FormLabel className='font-normal'>Medium</FormLabel>
+                      </FormItem>
+                      <FormItem className='flex items-center space-x-3 space-y-0'>
+                        <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>
+  )
+}

+ 35 - 0
src/features/tasks/context/tasks-context.tsx

@@ -0,0 +1,35 @@
+import React from 'react'
+import { Task } from '../data/schema'
+
+export type TasksDialogType = 'create' | 'update' | 'delete' | 'import'
+
+interface 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)
+
+interface Props {
+  children: React.ReactNode
+  value: TasksContextType
+}
+
+export default function TasksContextProvider({ children, value }: Props) {
+  return <TasksContext.Provider value={value}>{children}</TasksContext.Provider>
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useTasksContext = () => {
+  const tasksContext = React.useContext(TasksContext)
+
+  if (!tasksContext) {
+    throw new Error(
+      'useTasksContext has to be used within <TasksContext.Provider>'
+    )
+  }
+
+  return tasksContext
+}

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

@@ -1,3 +1,9 @@
+import { useState } from 'react'
+import { IconDownload, IconPlus } from '@tabler/icons-react'
+import useDialogState from '@/hooks/use-dialog-state'
+import { toast } from '@/hooks/use-toast'
+import { Button } from '@/components/ui/button'
+import { ConfirmDialog } from '@/components/confirm-dialog'
 import { Header } from '@/components/layout/header'
 import { Main } from '@/components/layout/main'
 import { ProfileDropdown } from '@/components/profile-dropdown'
@@ -5,11 +11,19 @@ import { Search } from '@/components/search'
 import { ThemeSwitch } from '@/components/theme-switch'
 import { columns } from './components/columns'
 import { DataTable } from './components/data-table'
+import { TasksImportDialog } from './components/tasks-import-dialog'
+import { TasksMutateDrawer } from './components/tasks-mutate-drawer'
+import TasksContextProvider, { TasksDialogType } from './context/tasks-context'
+import { Task } from './data/schema'
 import { tasks } from './data/tasks'
 
 export default function Tasks() {
+  // Local states
+  const [currentRow, setCurrentRow] = useState<Task | null>(null)
+  const [open, setOpen] = useDialogState<TasksDialogType>(null)
+
   return (
-    <>
+    <TasksContextProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
       {/* ===== Top Heading ===== */}
       <Header sticky>
         <Search />
@@ -27,11 +41,89 @@ export default function Tasks() {
               Here&apos;s a list of your tasks for this month!
             </p>
           </div>
+          <div className='flex gap-2'>
+            <Button
+              variant='outline'
+              className='space-x-1'
+              onClick={() => setOpen('import')}
+            >
+              <span>Import</span> <IconDownload size={18} />
+            </Button>
+            <Button className='space-x-1' onClick={() => setOpen('create')}>
+              <span>Create</span> <IconPlus size={18} />
+            </Button>
+          </div>
         </div>
         <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0'>
           <DataTable data={tasks} columns={columns} />
         </div>
       </Main>
-    </>
+
+      <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)
+              toast({
+                title: 'The following task has been deleted:',
+                description: (
+                  <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+                    <code className='text-white'>
+                      {JSON.stringify(currentRow, null, 2)}
+                    </code>
+                  </pre>
+                ),
+              })
+            }}
+            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'
+          />
+        </>
+      )}
+    </TasksContextProvider>
   )
 }