5.1 KiB
Forms, Modals & Mutations
This document explains how to implement interactive forms within the Basango dashboard. The process covers schema validation, React Hook Form (RHF) integration, using shadcn UI fields, wiring tRPC mutations, handling toasts, and controlling dialogs via Nuqs query parameters.
1. Define a Zod Schema
Describe the form shape locally using Zod. Example (SourceForm):
const sourceFormSchema = z.object({
description: z.string().optional().transform((value) => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}),
displayName: z
.string()
.optional()
.transform((value) => {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}),
name: z.string().trim().min(1, "Name is required").max(255),
url: z.string().trim().url("Enter a valid URL").max(255),
});
2. Initialize RHF with useZodForm
Use the shared hook useZodForm to connect Zod to RHF:
const form = useZodForm(sourceFormSchema, {
defaultValues: {
description: "",
displayName: "",
name: "",
url: "",
},
});
3. Build Inputs with <Controller /> & <Field />
Wrap each input using Controller so that we can access field and fieldState. Compose UI using shadcn Field primitives and Basango inputs:
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
{...field}
aria-invalid={fieldState.invalid}
autoComplete="off"
disabled={mutation.isPending}
id={field.name}
placeholder="radiookapi.com"
/>
<FieldDescription>This should match the unique identifier.</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Repeat for other controls (Input, Textarea, Select, etc.). Always pass aria-invalid and show <FieldError /> when needed.
4. Submit with SubmitButton
Use the shared SubmitButton to get the loading indicator:
<SubmitButton className="w-full" isSubmitting={mutation.isPending} type="submit">
Create source
</SubmitButton>
5. Wire the tRPC Mutation
Create the mutation via useTRPC():
const trpc = useTRPC();
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.sources.create.mutationOptions({
onError(error) {
toast.error(error.message ?? "Unable to create source.");
},
onSuccess() {
toast.success("Source created successfully.");
queryClient.invalidateQueries({
queryKey: trpc.sources.get.queryKey(),
});
form.reset();
onSuccess?.();
},
}),
);
In handleSubmit, call mutation.mutate(values).
6. Control Modals via Nuqs Query State
Dialogs that need to be opened from multiple places leverage Nuqs for query-parameter-driven state:
// apps/dashboard/src/hooks/use-source-params.ts
import { parseAsBoolean, useQueryStates } from "nuqs";
export function useSourceParams() {
const [params, setParams] = useQueryStates({
createSource: parseAsBoolean,
});
return { ...params, setParams };
}
Dialog Implementation
export function SourceCreateDialog() {
const { createSource, setParams } = useSourceParams();
const isOpen = Boolean(createSource);
const openDialog = () => setParams({ createSource: true });
const closeDialog = () => setParams(null);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => (open ? openDialog() : closeDialog())}
>
<Button onClick={openDialog} type="button">
<PlusIcon className="mr-2 size-4" />
Add source
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new source</DialogTitle>
<DialogDescription>Add a news outlet to track.</DialogDescription>
</DialogHeader>
<SourceForm onSuccess={closeDialog} />
</DialogContent>
</Dialog>
);
}
Because the dialog state lives in the query string, any server-rendered page or client button can open it by linking to ?createSource=true.
7. Page Integration
Include the dialog trigger where needed, e.g. apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/page.tsx:
<div className="mb-6 flex justify-end">
<SourceCreateDialog />
</div>
8. Toast Feedback
Use Sonner to provide async feedback within mutation callbacks (toast.success, toast.error). The Toaster is already mounted in the root layout.
9. Recap Checklist
- Define a Zod schema and create an RHF form via
useZodForm. - Use
Controller+ shadcnFieldprimitives for each input. - Use
SubmitButtonfor consistent loading states. - Wire
useTRPC().<namespace>.<mutation>.useMutation()with toast callbacks and query invalidation. - Drive modal state via Nuqs
useQueryStateshook so links/buttons can open the modal anywhere. - Reset the form after successful submission.
Following this pattern ensures forms, modals, and mutations behave consistently across the dashboard.