All files / features / projects / components ProjectForm.tsx

46.05% Statements 35/76
50% Branches 28/56
38.09% Functions 8/21
52.3% Lines 34/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139    69x   69x 69x 69x 69x 69x 6x 6x         6x 6x 6x 6x 6x 6x 6x 6x 6x   6x 6x                                                                                 6x 6x                                                                                                                                            
import { useState, forwardRef, useImperativeHandle, useEffect } from 'react'
import { useCreateProject, useUpdateProject } from '@/features/projects/projectsApi'
 
export interface CrudFormRef {
  submit: () => Promise<void>
}
 
export interface ProjectFormProps {
  id?: string
  defaults?: { name?: string; repo_url?: string; description?: string }
  onSuccess: (result: { id: string }) => void
  onClose: () => void
  onDirtyChange?: (dirty: boolean) => void
  onPendingChange?: (pending: boolean) => void
}
 
interface FormState {
  name: string
  repo_url: string
  description: string
}
 
const NAME_MIN = 3
const NAME_MAX = 100
const DESC_MAX = 5000
 
export const ProjectForm = forwardRef<CrudFormRef, ProjectFormProps>(function ProjectForm(
  { id, defaults, onSuccess, onClose: _onClose, onDirtyChange, onPendingChange },
  ref,
) {
  const [form, setForm] = useState<FormState>({
    name: defaults?.name ?? '',
    repo_url: defaults?.repo_url ?? '',
    description: defaults?.description ?? '',
  })
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [submitted, setSubmitted] = useState(false)

  const isEdit = !!id
  const { mutateAsync: createProject, isPending: isCreatePending } = useCreateProject()
  const { mutateAsync: updateProject, isPending: isUpdatePending } = useUpdateProject(id ?? '')
  const isPending = isCreatePending || isUpdatePending

  const isDirty =
    !submitted && (
      form.name !== (defaults?.name ?? '') ||
      form.repo_url !== (defaults?.repo_url ?? '') ||
      form.description !== (defaults?.description ?? '')
    )

  useEffect(() => { onDirtyChange?.(isDirty) }, [isDirty, onDirtyChange])
  useEffect(() => { onPendingChange?.(isPending) }, [isPending, onPendingChange])
 
  function validate(): Record<string, string> {
    const errs: Record<string, string> = {}
    if (!form.name || form.name.length < NAME_MIN) {
      errs.name = `Name must be at least ${NAME_MIN} characters`
    }
    if (form.name.length > NAME_MAX) {
      errs.name = `Name must be at most ${NAME_MAX} characters`
    }
    if (form.description.length > DESC_MAX) {
      errs.description = `Description must be at most ${DESC_MAX} characters`
    }
    return errs
  }
 
  async function handleSubmit() {
    if (isPending) return
    const errs = validate()
    setErrors(errs)
    if (Object.keys(errs).length > 0) return
 
    setSubmitted(true)
    try {
      if (isEdit) {
        await updateProject({ name: form.name, repo_url: form.repo_url, description: form.description || null })
        onSuccess({ id: id! })
      } else {
        const result = await createProject({ name: form.name, repo_url: form.repo_url, description: form.description || null })
        onSuccess({ id: result.id })
      }
    } catch {
      setSubmitted(false)
    }
  }
 
  useImperativeHandle(ref, () => ({
    submit: handleSubmit,
  }))
 
  return (
    <fieldset disabled={isPending} className="flex flex-col gap-4">
      <label className="flex flex-col gap-1 text-sm font-medium">
        Name
        <input
          className="rounded-md border border-input bg-background px-3 py-2 text-sm"
          value={form.name}
          onChange={(e) => {
            setForm((prev) => ({ ...prev, name: e.target.value }))
            setErrors((prev) => ({ ...prev, name: '' }))
          }}
          placeholder="Project name"
        />
        {errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
      </label>
      <label className="flex flex-col gap-1 text-sm font-medium">
        Repo URL
        <input
          className="rounded-md border border-input bg-background px-3 py-2 text-sm"
          value={form.repo_url}
          onChange={(e) => {
            setForm((prev) => ({ ...prev, repo_url: e.target.value }))
            setErrors((prev) => ({ ...prev, repo_url: '' }))
          }}
          placeholder="git@github.com:org/repo.git"
        />
        {errors.repo_url && <p className="text-sm text-destructive">{errors.repo_url}</p>}
      </label>
      <label className="flex flex-col gap-1 text-sm font-medium">
        Description
        <textarea
          className="rounded-md border border-input bg-background px-3 py-2 text-sm"
          value={form.description}
          onChange={(e) => {
            setForm((prev) => ({ ...prev, description: e.target.value }))
            setErrors((prev) => ({ ...prev, description: '' }))
          }}
          placeholder="Description (optional)"
          rows={3}
        />
        {errors.description && (
          <p className="text-sm text-destructive">{errors.description}</p>
        )}
      </label>
    </fieldset>
  )
})