Back to Spree

Add a custom field to the product form

docs/developer/dashboard/recipes/custom-form-field.mdx

5.6.06.8 KB
Original Source

Two ways to get a custom field onto the product form, both saved by the page's single Save button:

  • Custom field definition (no code) — declare the field as data; the dashboard renders it. Right for merchant-managed attributes and most plugin data.
  • Extension form field (code) — for a real database column your plugin added to spree_products. You register the field and render its input; hydration and saving stay with the host form.

Path A: custom field definition — no code

Create a definition for products (Settings → Custom fields, the in-place "Set up" button on the Custom Fields card, or the API):

bash
npx spree api post custom_field_definitions --data '{
  "resource_type": "Spree::Product",
  "namespace": "specs",
  "key": "tech_specs",
  "label": "Technical specifications",
  "field_type": "long_text"
}'

That's the whole feature. The Custom Fields card on the product form now renders a textarea for it; the value rides the form's custom_fields[] and persists when the merchant hits Save. Field types: short_text, long_text, rich_text, number, boolean, json.

Optional: replace the input widget

When the default widget isn't right — a color, a rating, a structured pair — register a component for that specific definition, keyed by its namespace.key:

tsx
// src/plugins.ts (or a plugin's entry module)
import { type CustomFieldComponentProps, defineDashboardPlugin } from '@spree/dashboard-core'

function ColorPicker({ id, value, onChange, ariaLabel }: CustomFieldComponentProps) {
  return (
    <input
      id={id}
      type="color"
      aria-label={ariaLabel}
      value={(value as string) || '#000000'}
      onChange={(e) => onChange(e.target.value)}
    />
  )
}

defineDashboardPlugin({
  customFieldComponents: {
    'specs.color': ColorPicker,
  },
})

The component is a controlled input — render value, call onChange. Persistence stays with the card (the form's Save on products and categories; the card's own Save on orders and customers). When no component is registered, the default widget for the definition's field_type renders.

Path B: extension form field — a real column

Use this when your plugin adds an actual column and API attribute.

1. Backend

ruby
class AddTechSpecsToSpreeProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_products, :tech_specs, :text, null: false, default: ''
  end
end

Expose it on the Admin API — the serializer returns tech_specs, and the products controller permits it on write (see Backend integration). Read and write names must match.

2. Register the field

Tell the product form about the field: where its value comes from on load. Everything else — dirty tracking, the PATCH payload, re-baselining after save — is the host form's job.

tsx
// src/plugins.ts (or a plugin's entry module)
import { defineDashboardPlugin } from '@spree/dashboard-core'

defineDashboardPlugin({
  formFields: {
    product: [
      // `from` receives the fetched product — or null on the create form.
      { name: 'tech_specs', from: (product) => product?.tech_specs ?? '' },
    ],
  },
})

3. Render the input from a slot

The product.form_sidebar slot renders inside the product form. Bind your input to the host form with useHostForm() — no <form> of your own, no save button:

tsx
// src/fields/tech-specs-card.tsx
import { useHostForm } from '@spree/dashboard-core'
import {
  Card, CardContent, CardHeader, CardTitle,
  Field, FieldError, FieldLabel, Textarea,
} from '@spree/dashboard-ui'
import { useTranslation } from 'react-i18next'

export function TechSpecsCard() {
  const { t } = useTranslation()
  const form = useHostForm<{ tech_specs: string }>()
  const error = form.formState.errors.tech_specs?.message

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('admin.fields.product.tech_specs.label')}</CardTitle>
      </CardHeader>
      <CardContent>
        <Field>
          <FieldLabel className="sr-only" htmlFor="tech_specs">
            {t('admin.fields.product.tech_specs.label')}
          </FieldLabel>
          <Textarea
            id="tech_specs"
            rows={6}
            placeholder={t('admin.fields.product.tech_specs.placeholder')}
            aria-invalid={Boolean(error)}
            {...form.register('tech_specs')}
          />
          {error && <FieldError>{error as string}</FieldError>}
        </Field>
      </CardContent>
    </Card>
  )
}
tsx
// src/plugins.ts — alongside the formFields registration
defineDashboardPlugin({
  formFields: {
    product: [{ name: 'tech_specs', from: (product) => product?.tech_specs ?? '' }],
  },
  slots: {
    'product.form_sidebar': [{ id: 'tech-specs', component: TechSpecsCard as never, position: 80 }],
  },
})

Done. The field hydrates when the page loads, participates in the form's dirty state (Save enables when it changes), ships in the same PATCH as every core field, and re-baselines after save.

Validation

Extension fields are validated by the server — a Rails validation failing returns a 422, and the host form's error mapping puts the message inline on your field (that's the errors.tech_specs read in the component). This mirrors how Vendure and Saleor treat extension-field validation: the backend is authoritative; the client renders what it says.

Which forms support this

useHostForm() works wherever a built-in form exposes its form context — currently the product (edit + new), category (edit + new), and store settings forms, with matching slots (product.form_sidebar, category.form_sidebar, store.form_main) and form keys (product, category, store). Orders and customers have no page-wide form — their sheets own their edits — so slot widgets there manage their own persistence; use useOptionalHostForm() to write a widget that adapts to both contexts.

Which path should I pick?

SituationPath
Merchant-defined attributes, plugin data without schema migrationsA — definition, zero code
Default widget is fineA
A real column with Rails validations, queries, or indexesB
The field belongs visually inside your own card with other UIB

Going further

To also surface the attribute as a table column with filtering and sorting, continue with A product attribute, end to end.

Reference