> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mercurjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Extend forms and tables

> Add validated fields to built-in forms, replace fields in detail sections, and add list columns with defineCustomFieldsConfig and createFormHelper.

`defineCustomFieldsConfig` is Mercur's model-scoped extension surface: from one file per model, you add validated fields to built-in create/edit forms, replace/remove/add fields in detail sections, and add columns to the list table — all wired into the built-in page, all typed against the model's generated registry.

<Info>
  **UI, not schema.** This helper is a *panel* surface — it renders, validates, and displays fields. It does not create database columns. To *store* extra data, use the backend [Custom Fields module](/rc/resources/customization/custom-fields) or your own API route/workflow. In the MVP, panel custom fields for `product` are submitted under `additional_data` and persisted onto the product's `metadata`.
</Info>

## What you'll build

An `ERP ID` field on the vendor product edit form, shown in the product's detail section and as a list-table column — from a single `src/custom-fields/product.tsx`.

## Register the typed targets (once)

Models, form zones, display zones, and built-in field ids are typed per panel. Register them once (shipped by `create-mercur-app`):

```typescript apps/vendor/src/extension-targets.d.ts theme={null}
/// <reference types="@mercurjs/vendor/extension-targets" />
```

## Build the config

<Steps>
  <Step title="Create the model file">
    Drop `src/custom-fields/<model>.tsx` and default-export a `defineCustomFieldsConfig`. `createFormHelper` (from `@mercurjs/dashboard-shared`) turns a Zod schema into an input type + validation:

    ```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
    import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
    import { createFormHelper } from "@mercurjs/dashboard-shared"

    type ProductWithMeta = { metadata?: Record<string, unknown> }
    const form = createFormHelper<ProductWithMeta>()

    export default defineCustomFieldsConfig({
      model: "product",
      forms: [
        {
          zone: "edit",
          fields: {
            erp_id: form.define({
              validation: form.string().optional(),
              label: "ERP ID",
              description: "External system identifier",
              placeholder: "ERP-000",
              defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
            }),
          },
        },
      ],
    })
    ```
  </Step>

  <Step title="Add a detail-section display">
    `displays[]` targets a detail-page section by its `zone` id. Keyed by field `id`, an entry **adds**, **replaces**, or **removes** a field:

    ```tsx theme={null}
    import { Text } from "@medusajs/ui"

    // ...inside defineCustomFieldsConfig:
    displays: [
      {
        zone: "general",
        fields: [
          // ADD — an unknown id appends a new read-only row
          {
            id: "erp_id",
            component: ({ data }) => (
              <Text size="small" className="text-ui-fg-subtle px-6 py-4">
                ERP ID: {String(data?.metadata?.erp_id ?? "-")}
              </Text>
            ),
          },
          // REMOVE — a built-in id + null hides the field
          { id: "subtitle", component: null },
          // REPLACE — a built-in id + component overrides its render
          {
            id: "handle",
            component: ({ data }) => (
              <Text size="small" className="text-ui-fg-subtle px-6 py-4">
                /{(data as { handle?: string })?.handle}
              </Text>
            ),
          },
        ],
      },
    ],
    ```

    Built-in field ids (like `subtitle`, `handle`, `status`, `title`) autocomplete from the panel's generated `CustomFieldsRegistry`; an unknown id is treated as an added row.
  </Step>

  <Step title="Add a list column">
    The `list` block extends the model's list table — add or override columns by id, hide built-in columns, and reorder:

    ```tsx theme={null}
    // ...inside defineCustomFieldsConfig:
    list: {
      columns: [
        {
          id: "erp_id",
          header: "ERP",
          component: ({ row }) => String(row.metadata?.erp_id ?? "-"),
        },
      ],
      viewDefaults: {
        columnVisibility: { collection: false }, // hide a built-in column
        columnOrder: ["product", "erp_id", "status"], // reorder
      },
    },
    ```
  </Step>

  <Step title="Reload the panel">
    Open the vendor portal. The product **edit** drawer shows the ERP ID field (validated on submit and persisted via `additional_data`), the detail **general** section shows the ERP ID row (with `subtitle` removed and `handle` re-rendered), and the product **list** shows the ERP column.
  </Step>
</Steps>

## The `createFormHelper` surface

`createFormHelper<T>()` exposes a Zod-based surface that drives both the input type and its validation:

```ts theme={null}
const form = createFormHelper<T>()

form.define({
  validation: form.string().min(1),   // string | number | boolean | date | array | object | null | nullable | coerce
  label: "…",
  description: "…",
  placeholder: "…",
  defaultValue: "" | ((data) => /* derive from the entity */),
  component,                            // optional custom render component
})
```

Fields render through the standard `Form.Field → Form.Item` chain (never a raw `Controller`) and participate in the existing `TabbedForm` / `RouteDrawer` submit and validation flow.

## Linked-module data

To read data from a linked module alongside the entity, declare it with `link`; those relations are fetched with the entity and become available to columns and displays:

```ts theme={null}
export default defineCustomFieldsConfig({
  model: "product",
  link: "brand", // or ["brand", "warranty"]
  list: {
    columns: [{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }],
  },
})
```

The SDK derives the fetch query from `link` and merges it into the built-in query with the `+`/`-` convention — you never hand-write the field list.

## Verify

1. The ERP ID field renders in the product edit drawer and validates on submit.
2. Saving persists the value (visible on reload) via `additional_data` → `metadata`.
3. The detail general section shows the ERP row, hides `subtitle`, and re-renders `handle`.
4. The product list shows the ERP column, hides `collection`, and reorders columns.
5. Set `zone: "nope"` in `forms` — `bun run lint` (tsc) fails against the model's registry.

## FAQ

<AccordionGroup>
  <Accordion title="Which models and zones are available?">
    Today: the `product` model in the vendor portal, with form zone `edit` and display zone `general`. The valid set per panel is generated into `CustomFieldsRegistry` in `extension-targets.d.ts` — autocomplete `model` / `zone` to see what's mounted.
  </Accordion>

  <Accordion title="Where is the value actually stored?">
    In the MVP, product custom fields are submitted under `additional_data` and persisted onto `product.metadata`. `defineCustomFieldsConfig` itself doesn't create a column — for durable, queryable storage model it with the backend [Custom Fields module](/rc/resources/customization/custom-fields) or a custom route/workflow.
  </Accordion>

  <Accordion title="Can I extend the onboarding wizard?">
    That's the same helper with `zone: "onboarding"` and `tab` set to a wizard step id (vendor only). It's designed but not mounted in the current MVP — the runtime host exists; the wizard mount is a follow-up.
  </Accordion>

  <Accordion title="Can a block ship custom fields?">
    Yes — a [block](/rc/learn/blocks) can include `src/custom-fields/` files in its `vendor_ui` / `admin_ui` entry, aggregated like the host app's.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Custom Fields module" href="/rc/resources/customization/custom-fields">
    Persist extra data on an entity with a generated side table.
  </Card>

  <Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
    Inject a component at a built-in zone.
  </Card>
</CardGroup>
