# Entity form

> A create dialog whose inputs come from `entityForm`: the entity's own fields and the organization's custom fields, validated together by `useEntityForm` before the Server Action runs.

- Blocks: [work-items](https://bettersupabase.com/blocks/work-items.md), [custom-fields](https://bettersupabase.com/blocks/custom-fields.md)
- Kind: component
- Hook: `useEntityForm` from `better-supabase/react`
- Page: https://bettersupabase.com/recipes/entity-form

## Install the blocks

```bash
npx better-supabase add work-items custom-fields
```

## Web (Next.js)

Add the shadcn components:

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

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

```tsx
import type { DbError } from "better-supabase";
import type { EntityFormInput } from "better-supabase/records";

import { workItemInputSchema } from "better-supabase/blocks/work-items";
import { useEntityForm } from "better-supabase/react";
import { entityForm } from "better-supabase/records";
import { PlusIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useMemo, useState } from "react";
import { toast } from "sonner";

import type { FieldRow } from "@/features/custom-fields/custom-fields-types";

import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useErrorMessage } from "@/lib/use-error-message";

import type { TeamOption } from "../work-types";

import { usePriorityLabel } from "../use-work-labels";
import { createWorkItem } from "../work-actions";

const NO_TEAM = "";

/** The HTML input type for each form input; the rest are text inputs. */
const INPUT_TYPES: Partial<Record<EntityFormInput, string>> = {
  number: "number",
  integer: "number",
  date: "date",
  datetime: "datetime-local",
  email: "email",
  url: "url",
};

/**
 * A dialog that creates a work item with a title, a team, a priority and the
 * organization's custom fields for work items. `entityForm` turns the entity
 * schema and the field definitions into one list of inputs and validates them
 * together.
 */
export function CreateWorkItemDialog({
  teams,
  defaultTeamId,
  customFields,
}: {
  teams: readonly TeamOption[];
  defaultTeamId: string | null;
  customFields: readonly FieldRow[];
}) {
  const t = useExtracted("work");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const priorityLabel = usePriorityLabel();
  const [open, setOpen] = useState(false);
  const [teamId, setTeamId] = useState(defaultTeamId ?? NO_TEAM);

  const definition = useMemo(
    () =>
      entityForm(workItemInputSchema, {
        fields: ["title", "priority"],
        customFields: customFields.map((field) => ({
          key: field.key,
          label: field.label,
          type: field.type,
          required: false,
          sensitive: field.sensitive,
        })),
      }),
    [customFields],
  );

  const [failure, setFailure] = useState<DbError>();

  const form = useEntityForm(definition, {
    initial: { title: "", priority: "0" },
    onSubmit: async ({ input, custom }) => {
      const result = await createWorkItem({
        title: input.title,
        teamId,
        priority: input.priority ?? 0,
        custom: Object.fromEntries(
          Object.entries(custom).map(([key, value]) => [key, String(value)]),
        ),
      });

      if (!result.ok) {
        setFailure(result.error);

        return;
      }

      toast.success(t("Created {identifier}", { identifier: result.data }));
      setOpen(false);
      form.reset();
    },
  });

  const error = failure === undefined ? null : errorMessage(failure);
  const textOf = (key: string) => String(form.values[key] ?? "");

  const teamItems = [
    { value: NO_TEAM, label: t("No team") },
    ...teams.map((team) => ({ value: team.id, label: team.name })),
  ];

  const priorityItems = ["0", "1", "2", "3", "4"].map((value) => ({
    value,
    label: priorityLabel(Number(value)),
  }));

  const custom = definition.fields.filter((field) => field.source === "custom");

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

        if (!next) {
          form.reset();
          setFailure(undefined);
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <PlusIcon />
        {t("New item")}
      </DialogTrigger>
      <DialogContent>
        <form
          onSubmit={(event) => {
            void form.submit(event);
          }}
          className="grid gap-6"
        >
          <DialogHeader>
            <DialogTitle>{t("New work item")}</DialogTitle>
            <DialogDescription>
              {t("It starts in the first stage of your workflow.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field
              data-invalid={error || form.errors["title"] ? true : undefined}
            >
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                name="title"
                required
                maxLength={200}
                autoComplete="off"
                value={textOf("title")}
                onChange={(event) => {
                  form.set("title", event.target.value);
                }}
              />
              {form.errors["title"] ? (
                <FieldError>{t("Enter a title.")}</FieldError>
              ) : null}
              {error ? <FieldError>{error}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-team`}>{t("Team")}</FieldLabel>
              <Select
                value={teamId}
                items={teamItems}
                onValueChange={(value) => {
                  setTeamId(value ?? NO_TEAM);
                }}
              >
                <SelectTrigger id={`${fieldId}-team`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {teamItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-priority`}>
                {t("Priority")}
              </FieldLabel>
              <Select
                value={textOf("priority")}
                items={priorityItems}
                onValueChange={(value) => {
                  form.set("priority", value ?? "0");
                }}
              >
                <SelectTrigger id={`${fieldId}-priority`}>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {priorityItems.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </Field>
            {custom.map((field) =>
              field.input === "bool" ? (
                <Field key={field.key} orientation="horizontal">
                  <Checkbox
                    id={`${fieldId}-${field.key}`}
                    checked={form.values[field.key] === true}
                    onCheckedChange={(checked) => {
                      form.set(field.key, checked);
                    }}
                  />
                  <FieldLabel htmlFor={`${fieldId}-${field.key}`}>
                    {field.label}
                  </FieldLabel>
                </Field>
              ) : (
                <Field
                  key={field.key}
                  data-invalid={form.errors[field.key] ? true : undefined}
                >
                  <FieldLabel htmlFor={`${fieldId}-${field.key}`}>
                    {field.label}
                  </FieldLabel>
                  <Input
                    id={`${fieldId}-${field.key}`}
                    type={INPUT_TYPES[field.input] ?? "text"}
                    autoComplete="off"
                    value={textOf(field.key)}
                    onChange={(event) => {
                      form.set(field.key, event.target.value);
                    }}
                  />
                  {form.errors[field.key] ? (
                    <FieldError>{form.errors[field.key]}</FieldError>
                  ) : null}
                </Field>
              ),
            )}
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={form.pending}>
              {t("Create item")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

## Prompt

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

### Web

```text
Build "Entity form" in a React web app that uses shadcn/ui components. A create dialog whose inputs come from `entityForm`: the entity's own fields and the organization's custom fields, validated together by `useEntityForm` before the Server Action runs.

It uses the better-supabase `work-items` block (Work items) with `custom-fields`. It calls the hook `useEntityForm` from `better-supabase/react`.

Steps:

1. If the app doesn't have the blocks yet, add the SQL with `npx better-supabase add work-items custom-fields` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add button checkbox dialog field input select`.
3. The recipe needs no glue: the hook calls the block from the client.
4. Write the component. Call `useEntityForm` from `better-supabase/react`, starting from the recipe's snippet (`apps/examples/nextjs/src/features/work/components/create-work-item-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 calls the same hook, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/entity-form.md
- Block docs: https://bettersupabase.com/docs/blocks/work-items
```
