瀏覽代碼

add herd create/delete, better placeholder for no data

Aneurin Barker Snook 1 年之前
父節點
當前提交
25c7eac494

+ 1 - 1
web/src/components/LoadingIndicator.scss

@@ -6,9 +6,9 @@
   flex-direction: row;
 
   svg {
+    @include icon-s;
     animation: 0.5s linear infinite spin;
     margin-right: $space-xs;
-    width: 20px;
   }
 }
 

+ 1 - 1
web/src/components/Pagination.tsx

@@ -13,7 +13,7 @@ export interface PaginationProps {
 export default function Pagination({ totalCount }: PropsWithChildren<PaginationProps>) {
   const { limit, page, ...routeSearch } = useRouteSearch()
 
-  const maxPage = Math.ceil(totalCount / limit)
+  const maxPage = Math.max(Math.ceil(totalCount / limit), 1)
 
   const can = {
     first: page > 1,

+ 13 - 0
web/src/components/Placeholder.scss

@@ -0,0 +1,13 @@
+@import '@/vars.scss';
+
+.placeholder {
+  align-items: center;
+  display: flex;
+  flex-direction: column;
+  padding: $space-l;
+  vertical-align: center;
+
+  svg {
+    @include icon-m;
+  }
+}

+ 14 - 0
web/src/components/Placeholder.tsx

@@ -0,0 +1,14 @@
+import './Placeholder.scss'
+import type { PropsWithChildren } from 'react'
+
+export interface PlaceholderProps {
+  className?: string
+}
+
+export default function Placeholder({ children, className = '' }: PropsWithChildren<PlaceholderProps>) {
+  return (
+    <div className={`placeholder ${className}`}>
+      {children}
+    </div>
+  )
+}

+ 1 - 1
web/src/components/SortableRow.scss

@@ -7,8 +7,8 @@
     margin-right: $space-s;
 
     svg {
+      @include icon-s;
       vertical-align: text-top;
-      width: 20px;
     }
   }
 

+ 1 - 1
web/src/components/button/Button.scss

@@ -102,8 +102,8 @@
   }
 
   svg {
+    @include icon-s;
     align-self: center;
-    width: 20px;
   }
 
   span ~ svg,

+ 1 - 1
web/src/layouts/app/ConnectionStatus.scss

@@ -2,8 +2,8 @@
 
 .connection-status {
   svg {
+    @include icon-s;
     align-self: center;
-    width: 20px;
   }
 
   span {

+ 8 - 0
web/src/vars.scss

@@ -68,3 +68,11 @@ $space-xl: 2rem;
     var(--color-bg) $size
   );
 }
+
+@mixin icon-s {
+  width: 20px;
+}
+
+@mixin icon-m {
+  width: 40px;
+}

+ 66 - 3
web/src/views/HerdListView.tsx

@@ -1,34 +1,78 @@
 import './HerdListView.scss'
 import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
 import CreateButton from '@/components/button/CreateButton'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
 import { Link } from 'react-router-dom'
 import LoadingIndicator from '@/components/LoadingIndicator'
 import Main from '@/components/Main'
 import Notice from '@/components/Notice'
 import Pagination from '@/components/Pagination'
+import ResetButton from '@/components/button/ResetButton'
 import Row from '@/components/Row'
+import SaveButton from '@/components/button/SaveButton'
 import SearchForm from '@/components/SearchForm'
 import api from '@/api'
-import { useConnection } from '@/hooks'
+import { useForm } from 'react-hook-form'
 import { useNavigate } from 'react-router-dom'
 import { useRouteSearch } from '@/hooks'
 import { useCallback, useEffect, useState } from 'react'
+import { useConnection, useSession } from '@/hooks'
+import Placeholder from '@/components/Placeholder'
+import { CloudIcon } from '@heroicons/react/20/solid'
+
+interface HerdCreateFormData extends Pick<api.Herd, 'name'> {}
+
+function useHerdCreateForm() {
+  const form = useForm<HerdCreateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    name: form.register('name', { validate: value => {
+      if (value.length < 1) return 'Required'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
 
 export default function ListHerds() {
+  const { account } = useSession()
+  const createHerdForm = useHerdCreateForm()
   const navigate = useNavigate()
   const { options } = useConnection()
   const { searchParams } = useRouteSearch()
 
+  const [busy, setBusy] = useState(false)
   const [error, setError] = useState<Error>()
   const [loading, setLoading] = useState(true)
   const [result, setResult] = useState<api.SearchResponse<api.GetHerdResponse>>()
 
+  async function createHerd(data: HerdCreateFormData) {
+    if (busy || !account) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      const res = await api.createHerd(options, {
+        herd: {
+          _account: account._id,
+          name: data.name,
+        },
+      })
+      navigate(`/herd/${res.herd._id}`)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
   const reload = useCallback(async () => {
     setLoading(true)
     setError(undefined)
     try {
       const res = await api.searchHerds(options, searchParams)
-      if (res.results.length === 0) throw new Error('No herds')
       setResult(res)
     } catch (err) {
       setError(err as Error)
@@ -64,7 +108,7 @@ export default function ListHerds() {
         <Notice error={error} />
       )}
 
-      {result && (
+      {result.metadata.totalCount > 0 ? (
         <>
           {result.results.map(({ herd }) => (
             <Row key={herd._id} className="herd">
@@ -76,7 +120,26 @@ export default function ListHerds() {
 
           <Pagination totalCount={result.metadata.totalCount} />
         </>
+      ) : (
+        <Placeholder>
+          <CloudIcon />
+          <span>No herds!</span>
+        </Placeholder>
       )}
+
+      <form onSubmit={createHerdForm.handleSubmit(createHerd)}>
+        <FormGroup name="Create a new herd">
+          <FormInput id="herd:name" label="Name">
+            <input id="herd:name" type="text" {...createHerdForm.inputs.name} />
+            <Chip className="mini" error={createHerdForm.formState.errors.name} />
+          </FormInput>
+
+          <ButtonSet>
+            <SaveButton type="submit" className="fill" />
+            <ResetButton type="reset" />
+          </ButtonSet>
+        </FormGroup>
+      </form>
     </Main>
   )
 }

+ 54 - 30
web/src/views/HerdView.tsx

@@ -4,6 +4,7 @@ import Button from '@/components/button/Button'
 import ButtonSet from '@/components/ButtonSet'
 import Chip from '@/components/Chip'
 import CreateButton from '@/components/button/CreateButton'
+import DeleteButton from '@/components/button/DeleteButton'
 import { DndContext } from '@dnd-kit/core'
 import type { DragEndEvent } from '@dnd-kit/core'
 import FormGroup from '@/components/form/FormGroup'
@@ -12,6 +13,7 @@ import LoadingIndicator from '@/components/LoadingIndicator'
 import Main from '@/components/Main'
 import Notice from '@/components/Notice'
 import Pagination from '@/components/Pagination'
+import Placeholder from '@/components/Placeholder'
 import ResetButton from '@/components/button/ResetButton'
 import Row from '@/components/Row'
 import SaveButton from '@/components/button/SaveButton'
@@ -19,12 +21,11 @@ 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 { CheckCircleIcon, CloudIcon, 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 DeleteButton from '@/components/button/DeleteButton'
 
 interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
 
@@ -101,6 +102,21 @@ export default function HerdView() {
     }
   }
 
+  async function deleteHerd() {
+    if (busy || !data) return
+
+    try {
+      setBusy(true)
+      setError(undefined)
+      await api.deleteHerd(options, data.herd._id)
+      navigate('/')
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
   async function deleteTask(task: api.WithId<api.Task>) {
     if (busy) return
 
@@ -111,7 +127,7 @@ export default function HerdView() {
       // Reload current page
       const taskRes = await api.searchTasks(options, id, searchParams)
       setTaskData(taskRes)
-    } catch(err) {
+    } catch (err) {
       setError(err as Error)
     } finally {
       setBusy(false)
@@ -235,6 +251,7 @@ export default function HerdView() {
 
         <ButtonSet>
           <BackButton onClick={() => navigate('/')} />
+          <DeleteButton onClick={deleteHerd} />
         </ButtonSet>
       </header>
 
@@ -244,33 +261,40 @@ export default function HerdView() {
 
       {taskData && (
         <>
-          <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>
-                    )}
-                    <DeleteButton className="mini" onClick={() => deleteTask(task)} />
-                  </ButtonSet>
-                </SortableRow>
-              ))}
-            </SortableContext>
-          </DndContext>
+          {taskData.metadata.totalCount > 0 ? (
+            <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>
+                      )}
+                      <DeleteButton className="mini" onClick={() => deleteTask(task)} />
+                    </ButtonSet>
+                  </SortableRow>
+                ))}
+              </SortableContext>
+            </DndContext>
+          ) : (
+            <Placeholder>
+              <CloudIcon />
+              <span>No tasks!</span>
+            </Placeholder>
+          )}
 
           <form onSubmit={createTaskForm.handleSubmit(createTask)}>
             <FormGroup name="Add a task">