1
0
Эх сурвалжийг харах

add drag-drop sorting and mark done/not done

Aneurin Barker Snook 1 жил өмнө
parent
commit
d618d9ad9f

+ 71 - 0
web/package-lock.json

@@ -8,6 +8,10 @@
       "name": "web",
       "version": "0.0.0",
       "dependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "@dnd-kit/modifiers": "^7.0.0",
+        "@dnd-kit/sortable": "^8.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
         "@heroicons/react": "^2.0.18",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
@@ -397,6 +401,68 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
+      "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz",
+      "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.0",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/modifiers": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz",
+      "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
+      "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.1.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@esbuild/android-arm": {
       "version": "0.19.8",
       "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.8.tgz",
@@ -3145,6 +3211,11 @@
         "typescript": ">=4.2.0"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+      "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

+ 4 - 0
web/package.json

@@ -10,6 +10,10 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@dnd-kit/core": "^6.1.0",
+    "@dnd-kit/modifiers": "^7.0.0",
+    "@dnd-kit/sortable": "^8.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
     "@heroicons/react": "^2.0.18",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",

+ 23 - 0
web/src/api/task.ts

@@ -35,6 +35,19 @@ export interface GetTaskResponse {
   task: WithId<Task>
 }
 
+/** Move task response data. */
+export interface MoveTaskResponse {
+  task: WithId<Task>
+  tasks: {
+    affectedCount: number
+  }
+}
+
+/** Toggle task done response data. */
+export interface ToggleTaskDoneResponse {
+  task: WithId<Task>
+}
+
 /** Update task request data. */
 export interface UpdateTaskRequest {
   task: Partial<Task>
@@ -60,11 +73,21 @@ export async function getTask(opt: Options, id: string): Promise<GetTaskResponse
   return request(opt, 'GET', `/task/${id}`)
 }
 
+/** Move a task. */
+export async function moveTask(opt: Options, id: string, position: number): Promise<MoveTaskResponse> {
+  return request(opt, 'PATCH', `/task/${id}/move/${position}`)
+}
+
 /** Search tasks. */
 export async function searchTasks(opt: Options, herd?: string, params?: SearchParams): Promise<SearchResponse<GetTaskResponse>> {
   return request(opt, 'GET', herd ? `/herd/${herd}/tasks` : '/tasks', params && writeSearchParams(params))
 }
 
+/** Toggle task done status. */
+export async function toggleTaskDone(opt: Options, id: string): Promise<ToggleTaskDoneResponse> {
+  return request(opt, 'PATCH', `/task/${id}/done`)
+}
+
 /** Update a task. */
 export async function updateTask(opt: Options, id: string, data: UpdateTaskRequest): Promise<UpdateTaskResponse> {
   return request(opt, 'PUT', `/task/${id}`, undefined, data)

+ 21 - 0
web/src/components/SortableRow.scss

@@ -0,0 +1,21 @@
+@import "@/vars.scss";
+
+.sortable-row {
+  .drag-handle {
+    cursor: move;
+    display: block;
+    margin-right: $space-s;
+
+    svg {
+      vertical-align: text-top;
+      width: 20px;
+    }
+  }
+
+  &.disabled {
+    .drag-handle svg {
+      fill: var(--color-inactive-fg);
+      cursor: default;
+    }
+  }
+}

+ 29 - 0
web/src/components/SortableRow.tsx

@@ -0,0 +1,29 @@
+import './SortableRow.scss'
+import { Bars3Icon } from '@heroicons/react/20/solid'
+import { CSS } from '@dnd-kit/utilities'
+import type { PropsWithChildren } from 'react'
+import type { RowProps } from './Row'
+import { useSortable } from '@dnd-kit/sortable'
+
+export interface SortableRowProps extends RowProps {
+  disabled?: boolean
+  id: string
+}
+
+export default function SortableRow({ children, className = '', disabled, id }: PropsWithChildren<SortableRowProps>) {
+  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id, disabled })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  return (
+    <div className={`row sortable-row ${className} ${disabled ? 'disabled' : ''}`} style={style} {...attributes}>
+      <div className="drag-handle" ref={setNodeRef} {...listeners}>
+        <Bars3Icon/>
+      </div>
+      {children}
+    </div>
+  )
+}

+ 7 - 1
web/src/views/HerdView.scss

@@ -5,7 +5,7 @@
   border-width: 0;
   border-radius: $radius-s;
   margin: $space-s 0;
-  padding: $space-s $space-m;
+  padding: $space-s $space-m $space-s $space-sm;
 
   .position {
     background: var(--color-bg);
@@ -18,4 +18,10 @@
   .description {
     flex-grow: 1;
   }
+
+  &.done {
+    .description {
+      color: var(--color-inactive-fg);
+    }
+  }
 }

+ 84 - 22
web/src/views/HerdView.tsx

@@ -2,7 +2,10 @@ import './HerdView.scss'
 import BackButton from '@/components/button/BackButton'
 import Button from '@/components/button/Button'
 import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
 import CreateButton from '@/components/button/CreateButton'
+import { DndContext } from '@dnd-kit/core'
+import type { DragEndEvent } from '@dnd-kit/core'
 import FormGroup from '@/components/form/FormGroup'
 import FormInput from '@/components/form/FormInput'
 import LoadingIndicator from '@/components/LoadingIndicator'
@@ -13,13 +16,14 @@ import ResetButton from '@/components/button/ResetButton'
 import Row from '@/components/Row'
 import SaveButton from '@/components/button/SaveButton'
 import SearchForm from '@/components/SearchForm'
+import SortableRow from '@/components/SortableRow'
 import api from '@/api'
+import { useForm } from 'react-hook-form'
 import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/20/solid'
+import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
 import { useCallback, useEffect, useState } from 'react'
 import { useConnection, useRouteSearch, useSession } from '@/hooks'
 import { useNavigate, useParams } from 'react-router-dom'
-import { useForm } from 'react-hook-form'
-import Chip from '@/components/Chip'
 
 interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
 
@@ -58,12 +62,15 @@ export default function HerdView() {
   const { options } = useConnection()
   const { limit, page, searchParams, setPage } = useRouteSearch()
 
-  const [busy, setBusy] = useState(false)
   const [data, setData] = useState<api.GetHerdResponse>()
   const [taskData, setTaskData] = useState<api.SearchResponse<api.GetTaskResponse>>()
+
+  const [busy, setBusy] = useState(false)
   const [error, setError] = useState<Error>()
   const [loading, setLoading] = useState(false)
 
+  const disableSorting = Boolean(searchParams.search)
+
   async function createTask(data: TaskCreateFormData) {
     if (busy) return
 
@@ -93,6 +100,54 @@ export default function HerdView() {
     }
   }
 
+  async function moveTask({ active, over }: DragEndEvent) {
+    if (busy || !taskData || !over || active.id === over.id) return
+
+    const activeIdx = taskData.results.findIndex(({ task }) => task._id === active.id)
+    const overIdx = taskData.results.findIndex(({ task }) => task._id === over.id)
+    if (activeIdx < 0 || overIdx < 0) return
+    const target = taskData.results[overIdx]
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      const update = await api.moveTask(options, active.id.toString(), target.task.position)
+      // Hot reorder tasks
+      const results = activeIdx < overIdx
+        // Task moved down
+        ? [
+          ...taskData.results.slice(0, overIdx + 1).filter(({ task }) => task._id !== update.task._id),
+          update,
+          ...taskData.results.slice(overIdx +1),
+        ]
+        // Task moved up
+        : [
+          ...taskData.results.slice(0, overIdx),
+          update,
+          ...taskData.results.slice(overIdx).filter(({ task }) => task._id !== update.task._id),
+        ]
+      setTaskData({ ...taskData, results })
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function toggleTaskDone(task: api.WithId<api.Task>) {
+    try {
+      setBusy(true)
+      setError(undefined)
+      const update = await api.toggleTaskDone(options, task._id)
+      const inPageTask = taskData?.results.find(({ task }) => task._id === update.task._id)
+      if (inPageTask) inPageTask.task.done = update.task.done
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
   const reload = useCallback(async () => {
     if (id) {
       setError(undefined)
@@ -171,25 +226,32 @@ export default function HerdView() {
 
       {taskData && (
         <>
-          {taskData.results.map(({ task }) => (
-            <Row key={task._id} className={`task ${task.done ? 'done' : 'not-done'}`}>
-              <div className="position">{task.position}</div>
-              <div className="description">{task.description}</div>
-              <ButtonSet>
-                {task.done ? (
-                  <Button className="positive mini fill">
-                    <CheckCircleIcon />
-                    <span>Done</span>
-                  </Button>
-                ) : (
-                  <Button className="negative mini">
-                    <XCircleIcon />
-                    <span>Not done</span>
-                  </Button>
-                )}
-              </ButtonSet>
-            </Row>
-          ))}
+          <DndContext onDragEnd={moveTask}>
+            <SortableContext
+              items={taskData.results.map(({ task }) => task._id)}
+              strategy={verticalListSortingStrategy}
+            >
+              {taskData.results.map(({ task }, i) => (
+                <SortableRow key={task._id} id={task._id} className={`task ${task.done ? 'done' : 'not-done'}`} disabled={disableSorting}>
+                  <div className="position">{i + 1 + ((page - 1) * limit)}</div>
+                  <div className="description">{task.description}</div>
+                  <ButtonSet>
+                    {task.done ? (
+                      <Button className="positive mini fill" onClick={() => toggleTaskDone(task)}>
+                        <CheckCircleIcon />
+                        <span>Done</span>
+                      </Button>
+                    ) : (
+                      <Button className="negative mini" onClick={() => toggleTaskDone(task)}>
+                        <XCircleIcon />
+                        <span>Not done</span>
+                      </Button>
+                    )}
+                  </ButtonSet>
+                </SortableRow>
+              ))}
+            </SortableContext>
+          </DndContext>
 
           <form onSubmit={createTaskForm.handleSubmit(createTask)}>
             <FormGroup name="Add a task">