Issue Report Form
Issue report form card with area and severity selects, a subject input and a details textarea under monospace uppercase labels. Validates with react-hook-form and zod, sends FormData to a React action, then resets.
pnpm dlx shadcn add @01orbital/issue-report-form"use client"
import {
useEffect,
useId,
useMemo,
useRef,
useTransition,
type ComponentProps,
type ReactNode,
} from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { cn } from "cn"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
// shadcn's stock field: an edge on the page, a light fill only in dark
const FIELD = "border-input bg-transparent dark:bg-input/30"
// The label look: mono, uppercase, wide tracking
const LABEL =
"font-mono text-xs font-normal tracking-widest text-muted-foreground uppercase"
type IssueOption = { value: string; label: string }
type IssueReportValues = {
area: string
severity: string
subject: string
details: string
}
type IssueReportFormProps = Omit<
ComponentProps<typeof Card>,
"title" | "children"
> & {
title: ReactNode
description?: ReactNode
areas: readonly IssueOption[]
severities: readonly IssueOption[]
defaultArea?: string
defaultSeverity?: string
labels: {
area: string
severity: string
select: string
subject: string
subjectPlaceholder: string
details: string
detailsPlaceholder: string
submit: ReactNode
// Shown under a field left empty; one message serves all four.
required?: string
}
// Receives validated FormData as a React Action. Fields reset after the
// transition completes, including work queued by a useActionState dispatch.
action?: (formData: FormData) => void | Promise<void>
}
/**
* A card with a form for reporting an issue: area, severity, subject and
* details. The area and severity fields sit side by side from the `sm`
* breakpoint.
*/
function IssueReportForm({
title,
description,
areas,
severities,
defaultArea,
defaultSeverity,
labels,
action,
className,
...props
}: IssueReportFormProps) {
const id = useId()
const [pending, startTransition] = useTransition()
const resetAfterAction = useRef(false)
const required = labels.required ?? "Required."
const schema = useMemo(
() =>
z.object({
area: z.string().min(1, required),
severity: z.string().min(1, required),
subject: z.string().trim().min(1, required),
details: z.string().trim().min(1, required),
}),
[required]
)
const form = useForm<IssueReportValues>({
resolver: zodResolver(schema),
defaultValues: {
area: defaultArea ?? "",
severity: defaultSeverity ?? "",
subject: "",
details: "",
},
})
const { reset } = form
useEffect(() => {
if (pending || !resetAfterAction.current) return
resetAfterAction.current = false
reset()
}, [pending, reset])
function onSubmit(values: IssueReportValues) {
if (!action) return
const formData = new FormData()
for (const [key, value] of Object.entries(values)) formData.set(key, value)
startTransition(async () => {
await action(formData)
resetAfterAction.current = true
})
}
return (
<Card
data-slot="issue-report-form"
className={cn("gap-8", className)}
{...props}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description != null && (
<CardDescription>{description}</CardDescription>
)}
</CardHeader>
<CardContent>
<form
id={`form-${id}`}
onSubmit={form.handleSubmit(onSubmit)}
noValidate
>
<FieldGroup>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
<Controller
name="area"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`area-${id}`} className={LABEL}>
{labels.area}
</FieldLabel>
<Select
name={field.name}
items={areas}
value={field.value || null}
onValueChange={(value) => field.onChange(value ?? "")}
>
<SelectTrigger
ref={field.ref}
id={`area-${id}`}
aria-label={labels.area}
aria-invalid={fieldState.invalid}
className={`${FIELD} w-full`}
onBlur={field.onBlur}
>
<SelectValue placeholder={labels.select} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{areas.map((area) => (
<SelectItem key={area.value} value={area.value}>
{area.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="severity"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`severity-${id}`} className={LABEL}>
{labels.severity}
</FieldLabel>
<Select
name={field.name}
items={severities}
value={field.value || null}
onValueChange={(value) => field.onChange(value ?? "")}
>
<SelectTrigger
ref={field.ref}
id={`severity-${id}`}
aria-label={labels.severity}
aria-invalid={fieldState.invalid}
className={`${FIELD} w-full`}
onBlur={field.onBlur}
>
<SelectValue placeholder={labels.select} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{severities.map((severity) => (
<SelectItem
key={severity.value}
value={severity.value}
>
{severity.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</FieldGroup>
<Controller
name="subject"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`subject-${id}`} className={LABEL}>
{labels.subject}
</FieldLabel>
<Input
{...field}
className={FIELD}
id={`subject-${id}`}
placeholder={labels.subjectPlaceholder}
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="details"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`details-${id}`} className={LABEL}>
{labels.details}
</FieldLabel>
<Textarea
{...field}
id={`details-${id}`}
placeholder={labels.detailsPlaceholder}
className={`${FIELD} min-h-24`}
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<Button
type="submit"
form={`form-${id}`}
className="w-full"
size="lg"
disabled={pending}
>
{labels.submit}
</Button>
</CardFooter>
</Card>
)
}
export { IssueReportForm }
Usage
import { IssueReportForm } from "@/components/forms/issue-report-form"Needs props to render
Pass
title, areas, severities, labels; see the API reference below.Examples
New
issue-report-form-stationery
API reference
A card with a form for reporting an issue: area, severity, subject and details. The area and severity fields sit side by side from the sm breakpoint.
Prop
Type
It also accepts the HTML attributes of the element it renders.
Dependencies
npm: @hookform/resolvers, cn, react-hook-form, zod. The CLI installs them for you.
Registry pieces it installs with it: