# Record form

> A create form whose inputs follow each column: a length limit for text, a select for options, a date input and a searchable picker for references.

- Block: [saved-views](https://bettersupabase.com/blocks/saved-views.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/record-form

## Install the block

```bash
npx better-supabase add saved-views
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add button combobox dialog field input select
```

From `apps/examples/nextjs/src/features/projects/components/task-form-dialog.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/projects/components/task-form-dialog.tsx):

```tsx
interface ReferenceOption {
  readonly value: string;
  readonly label: string;
}

/**
 * A searchable picker for a foreign key, such as `tasks.project_id`. It posts
 * the id in a hidden input, so the form stays a plain server action form.
 */
function ReferenceCombobox({
  id,
  name,
  options,
  defaultValue,
  placeholder,
  empty,
}: {
  id: string;
  name: string;
  options: readonly ReferenceOption[];
  defaultValue: string | null;
  placeholder: string;
  empty: string;
}) {
  const [value, setValue] = useState<ReferenceOption | null>(
    () => options.find((option) => option.value === defaultValue) ?? null,
  );

  return (
    <>
      <input type="hidden" name={name} value={value?.value ?? ""} />
      <Combobox
        items={options}
        value={value}
        onValueChange={setValue}
        itemToStringLabel={(option: ReferenceOption) => option.label}
        isItemEqualToValue={(a: ReferenceOption, b: ReferenceOption) =>
          a.value === b.value
        }
      >
        <ComboboxInput id={id} placeholder={placeholder} showClear />
        <ComboboxContent>
          <ComboboxEmpty>{empty}</ComboboxEmpty>
          <ComboboxList>
            {(option: ReferenceOption) => (
              <ComboboxItem key={option.value} value={option}>
                {option.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </>
  );
}

/**
 * The create form for a task. Each column gets the input its type and format
 * call for: text with its `max`, a select for `options`, a date input for
 * `date`, and a combobox for each reference.
 */
export function TaskFormDialog({
  projects,
  members,
  defaultProjectId,
}: {
  projects: readonly ProjectOption[];
  members: readonly MemberOption[];
  defaultProjectId: string | null;
}) {
  const t = useExtracted("projects");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const statusLabel = useStatusLabel();
  const [open, setOpen] = useState(false);
  const [status, setStatus] = useState<TaskStatus>("todo");

  const form = useActionForm(createTask, {
    onSuccess: () => {
      toast.success(t("Task created"));
      setOpen(false);
    },
  });

  const error = form.error === undefined ? null : errorMessage(form.error);

  const statusItems = TASK_STATUSES.map((value) => ({
    value,
    label: statusLabel(value),
  }));

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (!next) {
          form.reset();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <PlusIcon />
        {t("New task")}
      </DialogTrigger>
      <DialogContent>
        <form {...form.formProps} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("New task")}</DialogTitle>
            <DialogDescription>
              {t("It goes to the end of its status column.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                name="title"
                required
                maxLength={200}
                autoComplete="off"
              />
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-project`}>
                {t("Project")}
              </FieldLabel>
              <ReferenceCombobox
                id={`${fieldId}-project`}
                name="projectId"
                options={projects.map((project) => ({
                  value: project.id,
                  label: project.name,
                }))}
                defaultValue={defaultProjectId}
                placeholder={t("Pick a project")}
                empty={t("No projects match.")}
              />
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-status`}>
                {t("Status")}
              </FieldLabel>
              <input type="hidden" name="status" value={status} />
              <Select
                value={status}
                items={statusItems}
                onValueChange={(value) => {
                  setStatus(
                    TASK_STATUSES.find((each) => each === value) ?? "todo",
                  );
                }}
              >
                <SelectTrigger id={`${fieldId}-status`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {statusItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-assignee`}>
                {t("Assignee")}
              </FieldLabel>
              <ReferenceCombobox
                id={`${fieldId}-assignee`}
                name="assigneeId"
                options={members.map((member) => ({
                  value: member.userId,
                  label: member.name,
                }))}
                defaultValue={null}
                placeholder={t("Unassigned")}
                empty={t("No members match.")}
              />
            </Field>
            <div className="grid grid-cols-2 gap-4">
              <Field>
                <FieldLabel htmlFor={`${fieldId}-starts`}>
                  {t("Starts on")}
                </FieldLabel>
                <Input id={`${fieldId}-starts`} name="startsOn" type="date" />
              </Field>
              <Field>
                <FieldLabel htmlFor={`${fieldId}-due`}>
                  {t("Due on")}
                </FieldLabel>
                <Input id={`${fieldId}-due`} name="dueOn" type="date" />
              </Field>
            </div>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={form.pending}>
              {t("Create task")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Record form" in a React web app that uses shadcn/ui components. A create form whose inputs follow each column: a length limit for text, a select for options, a date input and a searchable picker for references.

It uses the better-supabase `saved-views` block (Saved views). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add saved-views` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add button combobox dialog field input select`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/projects/components/task-form-dialog.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/record-form.md
- Block docs: https://bettersupabase.com/docs/blocks/saved-views
```
