Browse Source

improve general busy ux, form structure incl separate herd edit view

Aneurin Barker Snook 1 năm trước cách đây
mục cha
commit
e0578c4a98

+ 7 - 2
web/src/routes.tsx

@@ -1,13 +1,14 @@
+import AccountSettingsView from './views/AccountSettingsView'
 import AppLayout from './layouts/AppLayout'
 import Authenticated from './components/Authenticated'
+import CreateAccountView from './views/CreateAccountView'
 import ErrorView from './views/ErrorView'
+import HerdEditView from './views/HerdEditView'
 import HerdListView from '@/views/HerdListView'
 import HerdView from './views/HerdView'
 import LoginView from './views/LoginView'
 import { Outlet } from 'react-router-dom'
 import type { RouteObject } from 'react-router-dom'
-import CreateAccountView from './views/CreateAccountView'
-import AccountSettingsView from './views/AccountSettingsView'
 
 const coreRoutes: RouteObject[] = [
   {
@@ -22,6 +23,10 @@ const coreRoutes: RouteObject[] = [
     path: '/herd/:id',
     element: <HerdView />,
   },
+  {
+    path: '/herd/:id/edit',
+    element: <HerdEditView />,
+  },
 ]
 
 const routes: RouteObject[] = [

+ 52 - 63
web/src/views/AccountSettingsView.tsx

@@ -1,32 +1,25 @@
+import BackButton from '@/components/button/BackButton'
 import ButtonSet from '@/components/ButtonSet'
 import Chip from '@/components/Chip'
-import CreateButton from '@/components/button/CreateButton'
+import DeleteButton from '@/components/button/DeleteButton'
 import FormGroup from '@/components/form/FormGroup'
 import FormInput from '@/components/form/FormInput'
-import { Link } from 'react-router-dom'
-import LoadingIndicator from '@/components/LoadingIndicator'
+import HideShowButton from '@/components/button/HideShowButton'
 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 { TrashIcon } from '@heroicons/react/20/solid'
 import api from '@/api'
 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, InboxIcon, TrashIcon } from '@heroicons/react/20/solid'
-import HideShowButton from '@/components/button/HideShowButton'
-import Button from '@/components/button/Button'
-import DeleteButton from '@/components/button/DeleteButton'
 
 interface AccountUpdateFormData extends Pick<api.Account, 'email' | 'password'> {}
 
-function useHerdCreateForm() {
+function useAccountUpdateForm() {
   const form = useForm<AccountUpdateFormData>({ mode: 'onBlur' })
 
   const inputs = {
@@ -43,14 +36,15 @@ function useHerdCreateForm() {
 }
 
 export default function AccountSettingsView() {
-  const { account, ...session } = useSession()
-  const updateAccountForm = useHerdCreateForm()
+  const form = useAccountUpdateForm()
   const navigate = useNavigate()
   const { options } = useConnection()
+  const { account, ...session } = useSession()
 
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState<Error>()
   const [passwordVisible, setPasswordVisible] = useState(false)
+  const [success, setSuccess] = useState(false)
 
   async function deleteAccount() {
     if (busy || !account) return
@@ -67,19 +61,20 @@ export default function AccountSettingsView() {
     }
   }
 
-  function resetAccountUpdate() {
+  const reset = useCallback(() => {
     if (!account) return
 
-    updateAccountForm.reset({
+    form.reset({
       email: account.email,
       password: '',
     })
-  }
+  }, [account, form])
 
   async function updateAccount(data: AccountUpdateFormData) {
     if (busy || !account) return
 
     try {
+      setSuccess(false)
       setBusy(true)
       setError(undefined)
       await api.updateAccount(options, account._id, {
@@ -89,7 +84,8 @@ export default function AccountSettingsView() {
         },
       })
       await session.heartbeat()
-      resetAccountUpdate()
+      reset()
+      setSuccess(true)
     } catch (err) {
       setError(err as Error)
     } finally {
@@ -98,73 +94,66 @@ export default function AccountSettingsView() {
   }
 
   useEffect(() => {
-    resetAccountUpdate()
+    reset()
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])
 
   return account && (
     <Main>
-      <header>
-        <h1>Your account</h1>
+      <form onSubmit={form.handleSubmit(updateAccount)}>
+        <header>
+          <h1>My account</h1>
 
-        <ButtonSet>
-          <Button className="fill" onClick={() => navigate('/')}>
-            <InboxIcon />
-            <span>Back to Herds</span>
-          </Button>
-        </ButtonSet>
-      </header>
+          <ButtonSet>
+            <BackButton onClick={() => navigate('/')} />
+            <ResetButton disabled={busy || !form.formState.isDirty} onClick={reset} className="outline" />
+            <SaveButton disabled={busy || !form.formState.isDirty} type="submit" className="fill" />
+          </ButtonSet>
+        </header>
 
-      <Notice error={error} />
+        {success && <Notice className="positive">Account updated.</Notice>}
 
-      <form onSubmit={updateAccountForm.handleSubmit(updateAccount)}>
-        <FormGroup name="Settings">
-          <p>You can update your login details below.</p>
+        <Notice error={error} />
 
+        <FormGroup name="Settings">
           <FormInput id="email" label="Email address">
-            <input id="email" type="text" {...updateAccountForm.inputs.email} />
-            <Chip className="mini" error={updateAccountForm.formState.errors.email} />
+            <input id="email" type="text" {...form.inputs.email} />
+            <Chip className="mini" error={form.formState.errors.email} />
           </FormInput>
 
           <FormInput id="password" label="Password">
             <Row className="hidden">
-              <input id="password" type={passwordVisible ? 'text' : 'password'} {...updateAccountForm.inputs.password} />
+              <input id="password" type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
               <ButtonSet>
                 <HideShowButton visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
               </ButtonSet>
             </Row>
-            {updateAccountForm.formState.errors.password && <Chip className="mini" error={updateAccountForm.formState.errors.password} />}
+            {form.formState.errors.password && <Chip className="mini" error={form.formState.errors.password} />}
           </FormInput>
-
-          <ButtonSet>
-            <SaveButton type="submit" className="fill" />
-            <ResetButton onClick={resetAccountUpdate} />
-          </ButtonSet>
         </FormGroup>
       </form>
 
-      <FormGroup name="Danger zone">
-        <p>
-          If you would like to stop using Herda, you can delete your account and all your data.
-        </p>
-
-        <Notice className="warn">Account deletion is not reversible. Proceed with care.</Notice>
-
-        <ButtonSet>
-          <DeleteButton
-            className="fill"
-            confirm={(
-              <>
-                <TrashIcon />
-                <span>Yes, really delete my account!</span>
-              </>
-            )}
-            onClick={deleteAccount}
-          >
-            <span>Delete my account</span>
-          </DeleteButton>
-        </ButtonSet>
-      </FormGroup>
+      <h2>Delete account</h2>
+
+      <p>
+        If you would like to stop using Herda, you can delete your account and all your data.
+        This is not reversible, so proceed with care.
+      </p>
+
+      <ButtonSet>
+        <DeleteButton
+          className="fill"
+          confirm={(
+            <>
+              <TrashIcon />
+              <span>Yes, really delete my account!</span>
+            </>
+          )}
+          onClick={deleteAccount}
+        >
+          <span>Delete my account</span>
+        </DeleteButton>
+      </ButtonSet>
     </Main>
   )
 }

+ 23 - 26
web/src/views/CreateAccountView.tsx

@@ -9,7 +9,6 @@ import { Link } from 'react-router-dom'
 import Main from '@/components/Main'
 import Notice from '@/components/Notice'
 import Row from '@/components/Row'
-import type { SubmitHandler } from 'react-hook-form'
 import api from '@/api'
 import { useForm } from 'react-hook-form'
 import { useSession } from '@/hooks'
@@ -25,9 +24,6 @@ interface AccountCreateFormData {
 function useAccountCreateForm() {
   const form = useForm<AccountCreateFormData>()
 
-  const [busy, setBusy] = useState(false)
-  const [error, setError] = useState<Error>()
-
   const inputs = {
     email: form.register('email', { validate: {
       required: value => value.length >= 1 || 'Required',
@@ -37,37 +33,38 @@ function useAccountCreateForm() {
     }}),
   }
 
-  return {
-    ...form,
-    inputs,
-    busy, setBusy,
-    error, setError,
-  }
+  return { ...form, inputs }
 }
 
 export default function CreateAccountView() {
   const doc = useDocument()
+  const form = useAccountCreateForm()
   const [params] = useSearchParams()
   const { options } = useConnection()
   const session = useSession()
 
-  const { formState: { errors }, ...form } = useAccountCreateForm()
-
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
   const [passwordVisible, setPasswordVisible] = useState(false)
   const redirectTo = params.get('redirect') || '/'
 
-  const submit: SubmitHandler<AccountCreateFormData> = async ({ email, password }) => {
-    form.setError(undefined)
-    form.setBusy(true)
+  async function submit(data: AccountCreateFormData) {
+    if (busy) return
+
+    setError(undefined)
+    setBusy(true)
     try {
       await api.createAccount(options, {
-        account: { email, password },
+        account: {
+          email: data.email,
+          password: data.password,
+        },
       })
-      await session.login(email, password)
+      await session.login(data.email, data.password)
     } catch (err) {
-      form.setError(err as Error)
+      setError(err as Error)
     } finally {
-      form.setBusy(false)
+      setBusy(false)
     }
   }
 
@@ -86,25 +83,25 @@ export default function CreateAccountView() {
       <form onSubmit={form.handleSubmit(submit)}>
         <FormGroup name="Create Account">
           <FormInput id="email" label="Email address">
-            <input id="email" type="text" {...form.inputs.email} />
-            {errors.email && <Chip className="mini" error={errors.email} />}
+            <input id="email" disabled={busy} type="text" {...form.inputs.email} />
+            {form.formState.errors.email && <Chip className="mini" error={form.formState.errors.email} />}
           </FormInput>
 
           <FormInput id="password" label="Password">
             <Row className="hidden">
-              <input id="password" type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
+              <input id="password" disabled={busy} type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
               <ButtonSet>
-                <HideShowButton visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
+                <HideShowButton disabled={busy} visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
               </ButtonSet>
             </Row>
-            {errors.password && <Chip className="mini" error={errors.password} />}
+            {form.formState.errors.password && <Chip className="mini" error={form.formState.errors.password} />}
           </FormInput>
 
           <ButtonSet>
-            <Button className="wide fill positive" disabled={form.busy} type="submit">Create and log in</Button>
+            <Button disabled={busy} className="wide fill positive" type="submit">Create and log in</Button>
           </ButtonSet>
 
-          <Notice error={form.error} />
+          <Notice error={error} />
 
           <section className="create-account">
             Have an account already? <Link to="/login">Log in</Link>

+ 151 - 0
web/src/views/HerdEditView.tsx

@@ -0,0 +1,151 @@
+import BackButton from '@/components/button/BackButton'
+import ButtonSet from '@/components/ButtonSet'
+import Chip from '@/components/Chip'
+import DeleteButton from '@/components/button/DeleteButton'
+import FormGroup from '@/components/form/FormGroup'
+import FormInput from '@/components/form/FormInput'
+import LoadingIndicator from '@/components/LoadingIndicator'
+import Main from '@/components/Main'
+import Notice from '@/components/Notice'
+import Placeholder from '@/components/Placeholder'
+import ResetButton from '@/components/button/ResetButton'
+import SaveButton from '@/components/button/SaveButton'
+import api from '@/api'
+import { useConnection } from '@/hooks'
+import { useForm } from 'react-hook-form'
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
+
+function useHerdUpdateForm() {
+  const form = useForm<HerdUpdateFormData>({ mode: 'onBlur' })
+
+  const inputs = {
+    name: form.register('name', { validate: value => {
+      if (value.length < 1) return 'Required'
+    }}),
+  }
+
+  return { ...form, inputs }
+}
+
+export default function HerdEditView() {
+  const { id } = useParams()
+  const navigate = useNavigate()
+  const { options } = useConnection()
+  const updateHerdForm = useHerdUpdateForm()
+
+  const [data, setData] = useState<api.GetHerdResponse>()
+
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
+  const [loading, setLoading] = useState(false)
+  const [success, setSuccess] = useState(false)
+
+  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)
+    }
+  }
+
+  const reload = useCallback(async () => {
+    if (id) {
+      setError(undefined)
+      setLoading(true)
+      try {
+        const res = await api.getHerd(options, id)
+        setData(res)
+      } catch (err) {
+        setError(err as Error)
+      } finally {
+        setLoading(false)
+      }
+    }
+  }, [id, options])
+
+  const reset = useCallback(() => {
+    if (data) updateHerdForm.reset({
+      name: data?.herd.name,
+    })
+    else updateHerdForm.reset()
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data])
+
+  async function submit(data: HerdUpdateFormData) {
+    console.log(busy)
+    if (busy) return
+
+    try {
+      setSuccess(false)
+      setBusy(true)
+      setError(undefined)
+      const res = await api.updateHerd(options, id as string, {
+        herd: data,
+      })
+      setData(res)
+      setSuccess(true)
+    } catch (err) {
+      setError(err as Error)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  useEffect(() => {
+    reload()
+  }, [reload])
+
+  useEffect(() => {
+    reset()
+  }, [reset])
+
+  return (
+    <Main>
+      <form onSubmit={updateHerdForm.handleSubmit(submit)}>
+        <header>
+          <h1>{data?.herd.name || 'Loading herd...'}</h1>
+
+          <ButtonSet>
+            <BackButton onClick={() => navigate(data ? `/herd/${data.herd._id}` : '/')} />
+            {data && (
+              <>
+                <ResetButton disabled={busy || !updateHerdForm.formState.isDirty} onClick={reset} className="outline" />
+                <SaveButton disabled={busy || !updateHerdForm.formState.isDirty} type="submit" className="fill" />
+                <DeleteButton disabled={busy || updateHerdForm.formState.isDirty} onClick={deleteHerd} />
+              </>
+            )}
+          </ButtonSet>
+        </header>
+
+        {loading && (
+          <Placeholder>
+            <LoadingIndicator />
+          </Placeholder>
+        )}
+
+        <Notice error={error} />
+
+        {success && <Notice className="positive">Herd updated.</Notice>}
+
+        {data && (
+          <FormGroup name="Settings">
+            <FormInput id="name" label="Name">
+              <input id="name" disabled={busy} type="text" {...updateHerdForm.inputs.name} />
+              <Chip className="mini" error={updateHerdForm.formState.errors.name} />
+            </FormInput>
+          </FormGroup>
+        )}
+      </form>
+    </Main>
+  )
+}

+ 29 - 44
web/src/views/HerdListView.tsx

@@ -1,6 +1,7 @@
 import './HerdListView.scss'
 import ButtonSet from '@/components/ButtonSet'
 import Chip from '@/components/Chip'
+import { CloudIcon } from '@heroicons/react/20/solid'
 import CreateButton from '@/components/button/CreateButton'
 import FormGroup from '@/components/form/FormGroup'
 import FormInput from '@/components/form/FormInput'
@@ -9,9 +10,8 @@ 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 Placeholder from '@/components/Placeholder'
 import Row from '@/components/Row'
-import SaveButton from '@/components/button/SaveButton'
 import SearchForm from '@/components/SearchForm'
 import api from '@/api'
 import { useForm } from 'react-hook-form'
@@ -19,13 +19,11 @@ 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 form = useForm<HerdCreateFormData>({ mode: 'onSubmit' })
 
   const inputs = {
     name: form.register('name', { validate: value => {
@@ -38,7 +36,7 @@ function useHerdCreateForm() {
 
 export default function ListHerds() {
   const { account } = useSession()
-  const createHerdForm = useHerdCreateForm()
+  const form = useHerdCreateForm()
   const navigate = useNavigate()
   const { options } = useConnection()
   const { searchParams } = useRouteSearch()
@@ -85,61 +83,48 @@ export default function ListHerds() {
     reload()
   }, [reload])
 
-  function Header() {
-    return (
+  return (
+    <Main>
       <header>
         <h1>Herds</h1>
-        <ButtonSet>
-          <CreateButton className="fill" onClick={() => navigate('/herds/create')} />
-        </ButtonSet>
       </header>
-    )
-  }
-
-  return result && (
-    <Main>
-      <Header />
 
       <SearchForm />
 
-      {loading ? (
-        <LoadingIndicator />
-      ) : (
-        <Notice error={error} />
-      )}
-
-      {result.metadata.totalCount > 0 ? (
-        <>
-          {result.results.map(({ herd }) => (
-            <Row key={herd._id} className="herd">
-              <div>
-                <Link to={`/herd/${herd._id}`}>{herd.name}</Link>
-              </div>
-            </Row>
-          ))}
+      <Notice error={error} />
 
-          <Pagination totalCount={result.metadata.totalCount} />
-        </>
-      ) : (
+      {loading ? (
+        <Placeholder>
+          <LoadingIndicator />
+        </Placeholder>
+      ) : (result && result.metadata.totalCount > 0) ? result.results.map(({ herd }) => (
+        <Row key={herd._id} className="herd">
+          <div>
+            <Link to={`/herd/${herd._id}`}>{herd.name}</Link>
+          </div>
+        </Row>
+      )) : (
         <Placeholder>
           <CloudIcon />
           <span>No herds!</span>
         </Placeholder>
       )}
 
-      <form onSubmit={createHerdForm.handleSubmit(createHerd)}>
+      <form onSubmit={form.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>
+            <Row>
+              <input id="name" disabled={busy} type="text" {...form.inputs.name} />
+              <ButtonSet>
+                <CreateButton disabled={busy} type="submit" className="fill" />
+              </ButtonSet>
+            </Row>
+            <Chip className="mini" error={form.formState.errors.name} />
           </FormInput>
-
-          <ButtonSet>
-            <SaveButton type="submit" className="fill" />
-            <ResetButton type="reset" />
-          </ButtonSet>
         </FormGroup>
       </form>
+
+      <Pagination totalCount={result?.metadata.totalCount || 0} />
     </Main>
   )
 }

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

@@ -4,9 +4,9 @@ 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 EditButton from '@/components/button/EditButton'
 import FormGroup from '@/components/form/FormGroup'
 import FormInput from '@/components/form/FormInput'
 import LoadingIndicator from '@/components/LoadingIndicator'
@@ -14,7 +14,6 @@ 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'
 import SearchForm from '@/components/SearchForm'
@@ -22,32 +21,18 @@ import SortableRow from '@/components/SortableRow'
 import type { SubmitHandler } from 'react-hook-form'
 import api from '@/api'
 import { useForm } from 'react-hook-form'
-import { CheckCircleIcon, CloudIcon, XCircleIcon, XMarkIcon } from '@heroicons/react/20/solid'
+import { CheckCircleIcon, CloudIcon, TrashIcon, XCircleIcon, XMarkIcon } 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'
 
-interface HerdUpdateFormData extends Pick<api.Herd, 'name'> {}
-
 interface TaskCreateFormData extends Pick<api.Task, 'description'> {}
 
 interface TaskUpdateFormData extends Pick<api.Task, 'description'> {}
 
-function useHerdUpdateForm() {
-  const form = useForm<HerdUpdateFormData>({ mode: 'onBlur' })
-
-  const inputs = {
-    name: form.register('name', { validate: value => {
-      if (value.length < 1) return 'Required'
-    }}),
-  }
-
-  return { ...form, inputs }
-}
-
 function useTaskCreateForm() {
-  const form = useForm<TaskCreateFormData>({ mode: 'onBlur' })
+  const form = useForm<TaskCreateFormData>({ mode: 'onSubmit' })
 
   const inputs = {
     description: form.register('description', { validate: value => {
@@ -72,12 +57,11 @@ function useTaskUpdateForm() {
 
 export default function HerdView() {
   const { account } = useSession()
-  const createTaskForm = useTaskCreateForm()
+  const createForm = useTaskCreateForm()
   const { id } = useParams()
   const navigate = useNavigate()
   const { options } = useConnection()
-  const updateHerdForm = useHerdUpdateForm()
-  const updateTaskForm = useTaskUpdateForm()
+  const updateForm = useTaskUpdateForm()
   const { limit, page, searchParams, setPage } = useRouteSearch()
 
   const [data, setData] = useState<api.GetHerdResponse>()
@@ -103,7 +87,7 @@ export default function HerdView() {
           description: data.description,
         },
       })
-      createTaskForm.reset({ description: '' })
+      createForm.reset({ description: '' })
       if (taskData && taskData.results.length >= limit) {
         // New task will be on a new page; change page to display it
         setPage(page + 1)
@@ -119,21 +103,6 @@ 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
 
@@ -219,29 +188,12 @@ export default function HerdView() {
   function setTaskToEdit(task?: api.WithId<api.Task>) {
     if (task) {
       setEditing(task._id)
-      updateTaskForm.reset({ description: task.description })
+      updateForm.reset({ description: task.description })
     } else {
       setEditing(undefined)
     }
   }
 
-  async function updateHerd(data: HerdUpdateFormData) {
-    if (busy) return
-
-    try {
-      setBusy(true)
-      setError(undefined)
-      const res = await api.updateHerd(options, id as string, {
-        herd: data,
-      })
-      setData(res)
-    } catch (err) {
-      setError(err as Error)
-    } finally {
-      setBusy(false)
-    }
-  }
-
   function updateTask(task: api.WithId<api.Task>): SubmitHandler<TaskUpdateFormData> {
     return async function(data) {
       if (busy) return
@@ -267,38 +219,16 @@ export default function HerdView() {
     reload()
   }, [reload])
 
-  if (loading) return (
-    <Main>
-      <header>
-        {id ? <h1>Loading Herd...</h1> : <h1>Create Herd</h1>}
-      </header>
-
-      <LoadingIndicator />
-    </Main>
-  )
-
-  if (error) return (
-    <Main>
-      <header>
-        {id ? <h1>Loading Herd...</h1> : <h1>Create Herd</h1>}
-
-        <ButtonSet>
-          <BackButton />
-        </ButtonSet>
-      </header>
-
-      <Notice error={error} />
-    </Main>
-  )
-
-  return data && (
+  return (
     <Main>
       <header>
-        <h1>{data.herd.name}</h1>
+        <h1>{data?.herd.name || 'Loading herd...'}</h1>
 
         <ButtonSet>
           <BackButton onClick={() => navigate('/')} />
-          <DeleteButton onClick={deleteHerd} />
+          {data && (
+            <EditButton className="outline" onClick={() => navigate(`/herd/${data.herd._id}/edit`)} />
+          )}
         </ButtonSet>
       </header>
 
@@ -306,94 +236,82 @@ export default function HerdView() {
 
       <Notice error={error} />
 
-      {taskData && (
-        <>
-          {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">
-                      {editing === task._id ? (
-                        <form onSubmit={updateTaskForm.handleSubmit(updateTask(task))}>
-                          <Row className="edit-task">
-                            <FormInput>
-                              <input type="text" autoFocus {...updateTaskForm.inputs.description} />
-                            </FormInput>
-                            <ButtonSet>
-                              <SaveButton type="submit" className="mini" />
-                              <Button onClick={() => setTaskToEdit(undefined)} className="mini">
-                                <XMarkIcon />
-                                <span>Cancel</span>
-                              </Button>
-                            </ButtonSet>
-                          </Row>
-                        </form>
-                      ) : (
-                        <span onClick={() => setTaskToEdit(task)}>{task.description}</span>
-                      )}
-                    </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">
-              <FormInput>
-                <Row>
-                  <input id="description" type="text" {...createTaskForm.inputs.description} />
-                  <ButtonSet>
-                    <CreateButton type="submit" className="fill" />
-                  </ButtonSet>
-                </Row>
-                <Chip className="mini" error={createTaskForm.formState.errors.description} />
-              </FormInput>
-            </FormGroup>
-          </form>
-
-          <Pagination totalCount={taskData.metadata.totalCount} />
-
-          <form onSubmit={updateHerdForm.handleSubmit(updateHerd)}>
-            <FormGroup name="Edit herd">
-              <FormInput id="herd:name" label="Name">
-                <input id="herd:name" type="text" {...updateHerdForm.inputs.name} />
-                <Chip className="mini" error={updateHerdForm.formState.errors.name} />
-              </FormInput>
+      {loading ? (
+        <Placeholder>
+          <LoadingIndicator />
+        </Placeholder>
+      ) : (taskData && 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">
+                  {editing === task._id ? (
+                    <form onSubmit={updateForm.handleSubmit(updateTask(task))}>
+                      <Row className="edit-task">
+                        <FormInput>
+                          <input type="text" disabled={busy} autoFocus {...updateForm.inputs.description} />
+                        </FormInput>
+                        <ButtonSet>
+                          <SaveButton type="submit" disabled={busy} className="mini" />
+                          <Button disabled={busy} onClick={() => setTaskToEdit(undefined)} className="mini">
+                            <XMarkIcon />
+                            <span>Cancel</span>
+                          </Button>
+                        </ButtonSet>
+                      </Row>
+                    </form>
+                  ) : (
+                    <span onClick={() => setTaskToEdit(task)}>{task.description}</span>
+                  )}
+                </div>
+                <ButtonSet>
+                  {task.done ? (
+                    <Button disabled={busy} className="positive mini fill" onClick={() => toggleTaskDone(task)}>
+                      <CheckCircleIcon />
+                      <span>Done</span>
+                    </Button>
+                  ) : (
+                    <Button disabled={busy} className="negative mini" onClick={() => toggleTaskDone(task)}>
+                      <XCircleIcon />
+                      <span>Not done</span>
+                    </Button>
+                  )}
+                  <Button disabled={busy} className="negative mini" onClick={() => deleteTask(task)}>
+                    <TrashIcon />
+                    <span>Delete</span>
+                  </Button>
+                </ButtonSet>
+              </SortableRow>
+            ))}
+          </SortableContext>
+        </DndContext>
+      ) : (
+        <Placeholder>
+          <CloudIcon />
+          <span>No tasks!</span>
+        </Placeholder>
+      )}
 
+      <form onSubmit={createForm.handleSubmit(createTask)}>
+        <FormGroup name="Add a task">
+          <FormInput>
+            <Row>
+              <input id="description" disabled={busy} type="text" {...createForm.inputs.description} />
               <ButtonSet>
-                <SaveButton type="submit" className="fill" />
-                <ResetButton type="reset" />
+                <CreateButton disabled={busy} type="submit" className="fill" />
               </ButtonSet>
-            </FormGroup>
-          </form>
-        </>
-      )}
+            </Row>
+            <Chip className="mini" error={createForm.formState.errors.description} />
+          </FormInput>
+        </FormGroup>
+      </form>
 
+      <Pagination totalCount={taskData?.metadata.totalCount || 0} />
     </Main>
   )
 }

+ 19 - 25
web/src/views/LoginView.tsx

@@ -9,7 +9,6 @@ import { Link } from 'react-router-dom'
 import Main from '@/components/Main'
 import Notice from '@/components/Notice'
 import Row from '@/components/Row'
-import type { SubmitHandler } from 'react-hook-form'
 import { useDocument } from '@/hooks'
 import { useForm } from 'react-hook-form'
 import { useSession } from '@/hooks'
@@ -24,9 +23,6 @@ interface LoginFormData {
 function useLoginForm() {
   const form = useForm<LoginFormData>()
 
-  const [busy, setBusy] = useState(false)
-  const [error, setError] = useState<Error>()
-
   const inputs = {
     email: form.register('email', { validate: {
       required: value => value.length >= 1 || 'Required',
@@ -36,33 +32,31 @@ function useLoginForm() {
     }}),
   }
 
-  return {
-    ...form,
-    inputs,
-    busy, setBusy,
-    error, setError,
-  }
+  return { ...form, inputs }
 }
 
 export default function LoginView() {
   const doc = useDocument()
+  const form = useLoginForm()
   const [params] = useSearchParams()
   const session = useSession()
 
-  const { formState: { errors }, ...form } = useLoginForm()
-
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState<Error>()
   const [passwordVisible, setPasswordVisible] = useState(false)
   const redirectTo = params.get('redirect') || '/'
 
-  const submit: SubmitHandler<LoginFormData> = async ({ email, password }) => {
-    form.setError(undefined)
-    form.setBusy(true)
+  async function submit(data: LoginFormData) {
+    if (busy) return
+
+    setError(undefined)
+    setBusy(true)
     try {
-      await session.login(email, password)
+      await session.login(data.email, data.password)
     } catch (err) {
-      form.setError(err as Error)
+      setError(err as Error)
     } finally {
-      form.setBusy(false)
+      setBusy(false)
     }
   }
 
@@ -81,25 +75,25 @@ export default function LoginView() {
       <form onSubmit={form.handleSubmit(submit)}>
         <FormGroup name="Login">
           <FormInput id="email" label="Email address">
-            <input id="email" type="text" {...form.inputs.email} />
-            {errors.email && <Chip className="mini" error={errors.email} />}
+            <input id="email" disabled={busy} type="text" {...form.inputs.email} />
+            {form.formState.errors.email && <Chip className="mini" error={form.formState.errors.email} />}
           </FormInput>
 
           <FormInput id="password" label="Password">
             <Row className="hidden">
-              <input id="password" type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
+              <input id="password" disabled={busy} type={passwordVisible ? 'text' : 'password'} {...form.inputs.password} />
               <ButtonSet>
-                <HideShowButton visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
+                <HideShowButton disabled={busy} visible={passwordVisible} onClick={() => setPasswordVisible(!passwordVisible)} />
               </ButtonSet>
             </Row>
-            {errors.password && <Chip className="mini" error={errors.password} />}
+            {form.formState.errors.password && <Chip className="mini" error={form.formState.errors.password} />}
           </FormInput>
 
           <ButtonSet>
-            <Button className="wide fill positive" disabled={form.busy} type="submit">Log in</Button>
+            <Button disabled={busy} className="wide fill positive" type="submit">Log in</Button>
           </ButtonSet>
 
-          <Notice error={form.error} />
+          <Notice error={error} />
 
           <section className="create-account">
             Don't have an account yet? <Link to="/account/create">Create one</Link>