@spree/docs 0.1.122 → 0.1.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/api-reference/admin-api/endpoints.md +1 -1
  2. package/dist/developer/cli/admin-api.md +11 -0
  3. package/dist/developer/dashboard/concepts.md +103 -0
  4. package/dist/developer/dashboard/customization/backend.md +126 -0
  5. package/dist/developer/dashboard/customization/navigation.md +211 -0
  6. package/dist/developer/dashboard/customization/permissions.md +123 -0
  7. package/dist/developer/dashboard/customization/quickstart.md +100 -0
  8. package/dist/developer/dashboard/customization/routes.md +187 -0
  9. package/dist/developer/dashboard/customization/slots.md +114 -0
  10. package/dist/developer/dashboard/customization/tables.md +167 -0
  11. package/dist/developer/dashboard/customization/translations.md +96 -0
  12. package/dist/developer/dashboard/deployment.md +80 -0
  13. package/dist/developer/dashboard/overview.md +105 -0
  14. package/dist/developer/dashboard/plugins/distributing.md +137 -0
  15. package/dist/developer/dashboard/plugins/overview.md +46 -0
  16. package/dist/developer/dashboard/plugins/publishing.md +142 -0
  17. package/dist/developer/dashboard/plugins/scaffolding.md +133 -0
  18. package/dist/developer/dashboard/public-api.md +241 -0
  19. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +212 -0
  20. package/dist/developer/dashboard/recipes/custom-form-field.md +174 -0
  21. package/dist/developer/dashboard/recipes/page-action-button.md +189 -0
  22. package/dist/developer/dashboard/recipes/sidebar-widget.md +147 -0
  23. package/dist/developer/dashboard/slots-catalog.md +173 -0
  24. package/dist/developer/sdk/admin/extending.md +70 -0
  25. package/dist/developer/sdk/admin/quickstart.md +3 -3
  26. package/dist/developer/tutorial/admin.md +2 -0
  27. package/dist/developer/tutorial/api.md +2 -0
  28. package/dist/developer/tutorial/extending-models.md +2 -0
  29. package/dist/developer/tutorial/model.md +2 -0
  30. package/package.json +1 -1
@@ -0,0 +1,241 @@
1
+ ---
2
+ title: Public API
3
+ sidebarTitle: Public API
4
+ description: Every importable symbol the dashboard exposes — components, hooks, registries, providers, and the SDK client. Treat this as the surface you can safely depend on.
5
+ ---
6
+
7
+ The dashboard is structured as three packages, each with a documented public surface. **Anything not listed here is internal** — don't import it via `@spree/dashboard-core/src/internal/...`, because we'll move or rename it without a deprecation cycle.
8
+
9
+ ## `@spree/dashboard-core`
10
+
11
+ Default-import surface for plugins and customizations.
12
+
13
+ ### Plugin facade
14
+
15
+ ```ts
16
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
17
+ ```
18
+
19
+ Single declarative entry-point for every registry. See [Customization Quickstart](customization/quickstart.md) for the full shape.
20
+
21
+ ### Registries
22
+
23
+ ```ts
24
+ import {
25
+ nav,
26
+ settingsNav,
27
+ // route registry
28
+ pluginRoutes,
29
+ matchPluginRoute,
30
+ usePluginRoutes,
31
+ // slot registry
32
+ registerSlot,
33
+ removeSlot,
34
+ updateSlot,
35
+ useSlotEntries,
36
+ // table registry
37
+ tables,
38
+ // extension fields on built-in forms (hydrate + save with the host form)
39
+ formFields,
40
+ // input components for specific custom field definitions
41
+ customFieldComponents,
42
+ } from '@spree/dashboard-core'
43
+ ```
44
+
45
+ Each registry exposes `add`/`remove`/`update`-style mutators. See the per-feature pages for examples — form fields and custom field components are covered in the [custom form field recipe](recipes/custom-form-field.md).
46
+
47
+ ### Components
48
+
49
+ ```ts
50
+ import {
51
+ // Page chrome
52
+ PageHeader,
53
+ PageTabs,
54
+ TopBar,
55
+ AppSidebar,
56
+ SettingsSidebar,
57
+ Slot,
58
+ StoreSwitcher,
59
+ // Tables
60
+ ResourceTable,
61
+ TableToolbar,
62
+ BulkActionBar,
63
+ // Form widgets
64
+ StoreDatePicker,
65
+ CountryCombobox,
66
+ CountryStateFields,
67
+ CurrencySelect,
68
+ LocaleSelect,
69
+ MarketCombobox,
70
+ ResourceCombobox,
71
+ ResourceMultiAutocomplete,
72
+ TagCombobox,
73
+ PreferencesForm,
74
+ AddressFormDialog,
75
+ // Permission
76
+ Can,
77
+ // Export
78
+ ExportButton,
79
+ } from '@spree/dashboard-core'
80
+ ```
81
+
82
+ ### Hooks
83
+
84
+ ```ts
85
+ import {
86
+ useAuth, // current admin user
87
+ usePermissions, // CanCanCan abilities
88
+ useStore, // current store
89
+ useCommandPalette, // ⌘K palette open/close state
90
+ useGlobalSearch, // global search query state
91
+ useCountries,
92
+ useCustomFields,
93
+ useDirectUpload, // ActiveStorage direct uploads
94
+ useExport, // CSV export job lifecycle
95
+ useResourceMutation, // useMutation wrapper with 422 handling
96
+ useHostForm, // the built-in form a slot widget renders inside (throws when absent)
97
+ useOptionalHostForm, // same, but null when the page has no host form
98
+ } from '@spree/dashboard-core'
99
+ ```
100
+
101
+ ### Helpers
102
+
103
+ ```ts
104
+ import {
105
+ filtersToRansack, // converts table filter state → Ransack params
106
+ mapSpreeErrorsToForm, // 422 response → form.setError
107
+ // formatters
108
+ formatPrice, // Price object → display string
109
+ formatStoreDateTime, // ISO string → wall-clock in the store's timezone
110
+ getInitials, // full name → avatar initials
111
+ // i18n
112
+ i18n, // configured instance
113
+ // form mappers — normalize optional text inputs before submitting
114
+ blankToNull,
115
+ blankToUndefined,
116
+ } from '@spree/dashboard-core'
117
+ ```
118
+
119
+ ### SDK client
120
+
121
+ ```ts
122
+ import { adminClient } from '@spree/dashboard-core'
123
+ ```
124
+
125
+ The shared `@spree/admin-sdk` instance configured for the host's API URL. Use this everywhere instead of constructing your own.
126
+
127
+ ### Providers
128
+
129
+ ```ts
130
+ import {
131
+ AuthProvider,
132
+ PermissionProvider,
133
+ StoreProvider,
134
+ } from '@spree/dashboard-core'
135
+ ```
136
+
137
+ Mount these at the root of your app shell. The shipped app shell does this for you — re-export only if you're composing a custom shell (vendor panel, etc.).
138
+
139
+ ### Vite plugin
140
+
141
+ ```ts
142
+ import { spreeDashboardPlugin } from '@spree/dashboard-core/vite'
143
+ ```
144
+
145
+ Vite plugin that resolves third-party dashboard plugins and injects Tailwind `@source` directives for their CSS. See [Distributing](plugins/distributing.md) for the host wiring.
146
+
147
+ ## `@spree/dashboard-ui`
148
+
149
+ Design system primitives. Headless — accept data via props, never reach for providers or hooks.
150
+
151
+ ### shadcn-style primitives
152
+
153
+ ```ts
154
+ import {
155
+ Button,
156
+ Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter,
157
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
158
+ Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
159
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
160
+ Input, Textarea, Checkbox, RadioGroup, Switch,
161
+ Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
162
+ Combobox,
163
+ Field, FieldLabel, FieldError, FieldDescription,
164
+ Table, TableHeader, TableBody, TableRow, TableCell,
165
+ Badge,
166
+ Avatar,
167
+ Skeleton,
168
+ Tooltip, TooltipTrigger, TooltipContent,
169
+ Toaster,
170
+ } from '@spree/dashboard-ui'
171
+ ```
172
+
173
+ Toast *notifications* come from [sonner](https://sonner.emilkowal.ski/) directly — `import { toast } from 'sonner'` — while `<Toaster>` (the mount point, already rendered by the app shell) is the only part re-exported here.
174
+
175
+ ### Composed components
176
+
177
+ Higher-level pieces that bundle multiple primitives but still take data via props:
178
+
179
+ ```ts
180
+ import {
181
+ ResourceLayout, // header + main + sidebar grid
182
+ Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
183
+ ColorPicker,
184
+ // …more land here as we extract from @spree/dashboard
185
+ } from '@spree/dashboard-ui'
186
+ ```
187
+
188
+ `Empty*` composes via children (icon in `<EmptyMedia>`, actions in `<EmptyContent>`) rather than taking `title`/`description` props.
189
+
190
+ ### Utilities
191
+
192
+ ```ts
193
+ import { cn } from '@spree/dashboard-ui' // clsx + tailwind-merge
194
+ ```
195
+
196
+ ## `@spree/dashboard`
197
+
198
+ The app shell. **Plugins don't import from here** — a plugin extends the dashboard through `@spree/dashboard-core` and never renders the shell. The host (the `dashboard-starter` project, or a create-spree-app project) is what imports it, to mount the admin:
199
+
200
+ ```ts
201
+ import { createDashboardRouter, Dashboard } from '@spree/dashboard'
202
+ ```
203
+
204
+ - **`Dashboard`** — the app shell component (provider stack + router). Render it with a router: `<Dashboard router={router} />`.
205
+ - **`createDashboardRouter(routeTree)`** — builds the router from the host's generated `routeTree.gen.ts` (the shell's routes composed with every installed plugin's file routes). The starter's `main.tsx` wires these together; you rarely write this by hand.
206
+
207
+ Two subpath entries complete the surface:
208
+
209
+ ```ts
210
+ import { spreeDashboardPlugin } from '@spree/dashboard/vite' // Vite plugin: Tailwind, discovery, route composition
211
+ import '@spree/dashboard/styles.css' // the app's CSS entry
212
+ ```
213
+
214
+ Importing from `@spree/dashboard` pins your code to the deployable app's version. Plugins and custom UI should prefer `@spree/dashboard-core` + `@spree/dashboard-ui`.
215
+
216
+ ## What about `@spree/admin-sdk`?
217
+
218
+ The Admin API client is a separate package and a public dependency. Import types and `SpreeError` from it directly:
219
+
220
+ ```ts
221
+ import {
222
+ type Product,
223
+ type Order,
224
+ type Customer,
225
+ SpreeError,
226
+ } from '@spree/admin-sdk'
227
+ ```
228
+
229
+ The `adminClient` instance, by contrast, comes from `@spree/dashboard-core` so the host owns the configured singleton.
230
+
231
+ ## Stability
232
+
233
+ Anything on this page is covered by semver from `@spree/dashboard-core` 1.0.0 onward. Pre-1.0 we may rename, but we'll call breaking moves out in the changelog.
234
+
235
+ Anything not listed here is internal. If you find yourself wanting it, open a discussion — it's a sign we should promote it to the public surface.
236
+
237
+ ## Reference
238
+
239
+ - [`packages/dashboard-core/src/index.ts`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/index.ts) — canonical export list
240
+ - [`packages/dashboard-ui/src/index.ts`](https://github.com/spree/spree/blob/main/packages/dashboard-ui/src/index.ts)
241
+ - [`packages/admin-sdk/src/index.ts`](https://github.com/spree/spree/blob/main/packages/admin-sdk/src/index.ts)
@@ -0,0 +1,212 @@
1
+ ---
2
+ title: "A product attribute, end to end: form, table, filters, sort"
3
+ sidebarTitle: Attribute end-to-end
4
+ description: Take one custom product attribute all the way — editable on the product form, visible as a table column, filterable and sortable in the products list.
5
+ ---
6
+
7
+ This recipe adds a **supplier lead time** (`lead_time_days`, an integer) to products and wires it through every surface the dashboard has:
8
+
9
+ - edited on the product form, saved by the form's own Save button
10
+ - a column in the products table
11
+ - a filter ("lead time > 7 days") and a sort ("slowest first") in the list toolbar
12
+
13
+ It's the full version of the [custom form field recipe](custom-form-field.md) — read that one first if you only need the form.
14
+
15
+ ## First: should this be a custom field instead?
16
+
17
+ **Default to a [custom field definition](custom-form-field.md#path-a-custom-field-definition--no-code).** It needs zero code — declare the field, and the product form renders and saves it. That covers most plugin data and everything merchants define themselves.
18
+
19
+ Graduate to a real database column only when the attribute needs **list operations or database guarantees**:
20
+
21
+ | You need… | Custom field | Real column |
22
+ |---|---|---|
23
+ | Editing on the product form | ✅ zero code | ✅ this recipe |
24
+ | Filtering / sorting the products list in Admin | ❌ | ✅ via Ransack |
25
+ | Merchants defining the field themselves at runtime | ✅ | ❌ |
26
+
27
+ Lead time is a column because its whole point is the query: *"show me everything that ships slow, slowest first."*
28
+
29
+ ## 1. Backend
30
+
31
+ Four small pieces — each one unlocks a specific frontend capability. In a host app these live in `backend/` (or `api/`); in a distributed plugin, in the Rails engine half.
32
+
33
+ **The column** (with an index — you're adding it *because* it will be queried):
34
+
35
+ ```ruby
36
+ class AddLeadTimeDaysToSpreeProducts < ActiveRecord::Migration[7.2]
37
+ def change
38
+ add_column :spree_products, :lead_time_days, :integer
39
+ add_index :spree_products, :lead_time_days
40
+ end
41
+ end
42
+ ```
43
+
44
+ **The serializer** — makes the attribute *readable* (hydrates the form, renders the table cell):
45
+
46
+ ```ruby
47
+ # app/serializers/admin/product_serializer.rb
48
+ module Admin
49
+ class ProductSerializer < Spree::Api::V3::Admin::ProductSerializer
50
+ attributes :lead_time_days
51
+ end
52
+ end
53
+
54
+ # config/initializers/spree.rb
55
+ Spree.api.admin_product_serializer = 'Admin::ProductSerializer'
56
+ ```
57
+
58
+ **The permitted param** — makes it *writable* (the form's Save ships it in the same PATCH as core fields). The read and write names must match:
59
+
60
+ ```ruby
61
+ # app/controllers/spree/api/v3/admin/products_controller_decorator.rb
62
+ module Spree
63
+ module Api
64
+ module V3
65
+ module Admin
66
+ module ProductsControllerDecorator
67
+ def permitted_params
68
+ super.merge(params.permit(:lead_time_days))
69
+ end
70
+ end
71
+
72
+ ProductsController.prepend(ProductsControllerDecorator)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ ```
78
+
79
+ **The Ransack allowlist** — makes it *filterable and sortable*. Both the list's sort param and every filter predicate go through Ransack, and Ransack refuses attributes that aren't allowlisted:
80
+
81
+ ```ruby
82
+ # config/initializers/spree.rb
83
+ Spree::Product.whitelisted_ransackable_attributes |= %w[lead_time_days]
84
+ ```
85
+
86
+ Add a Rails validation while you're here (`validates :lead_time_days, numericality: { greater_than_or_equal_to: 0, allow_nil: true }`) — it's the authoritative validation, and the dashboard renders its 422 message inline on the field automatically.
87
+
88
+ ## 2. Frontend — one declaration
89
+
90
+ ```tsx
91
+ // src/plugins.ts (or a plugin's entry module)
92
+ import { defineDashboardPlugin, i18n } from '@spree/dashboard-core'
93
+ import { LeadTimeCard } from './fields/lead-time-card'
94
+
95
+ i18n.addResourceBundle('en', 'translation', en, true, true)
96
+
97
+ defineDashboardPlugin({
98
+ // Form: hydrate from the fetched product, save with the form's own Save.
99
+ formFields: {
100
+ product: [{ name: 'lead_time_days', from: (p) => p?.lead_time_days ?? null }],
101
+ },
102
+ slots: {
103
+ 'product.form_sidebar': [{ id: 'lead-time', component: LeadTimeCard as never, position: 60 }],
104
+ },
105
+
106
+ // Table: column + filter + sort in one ColumnDef.
107
+ tables: {
108
+ products: {
109
+ add: [{
110
+ key: 'lead_time_days',
111
+ label: i18n.t('admin.fields.product.lead_time_days.label'),
112
+ sortable: true, // header click → server-side sort via Ransack
113
+ filterable: true, // appears in the filter panel
114
+ filterType: 'number', // numeric operator set: =, ≠, >, <, ranges
115
+ default: true, // visible out of the box (false = opt-in via column picker)
116
+ render: (product) => (product.lead_time_days != null ? `${product.lead_time_days} d` : '—'),
117
+ }],
118
+ },
119
+ },
120
+ })
121
+ ```
122
+
123
+ The form widget binds to the host form — no `<form>` of its own, no save button:
124
+
125
+ ```tsx
126
+ // src/fields/lead-time-card.tsx
127
+ import { useHostForm } from '@spree/dashboard-core'
128
+ import { Card, CardContent, CardHeader, CardTitle, Field, FieldError, FieldLabel, Input } from '@spree/dashboard-ui'
129
+ import { useTranslation } from 'react-i18next'
130
+
131
+ export function LeadTimeCard() {
132
+ const { t } = useTranslation()
133
+ const form = useHostForm<{ lead_time_days: number | null }>()
134
+ const error = form.formState.errors.lead_time_days?.message
135
+
136
+ return (
137
+ <Card>
138
+ <CardHeader>
139
+ <CardTitle>{t('admin.fields.product.lead_time_days.label')}</CardTitle>
140
+ </CardHeader>
141
+ <CardContent>
142
+ <Field>
143
+ <FieldLabel className="sr-only" htmlFor="lead_time_days">
144
+ {t('admin.fields.product.lead_time_days.label')}
145
+ </FieldLabel>
146
+ <Input
147
+ id="lead_time_days"
148
+ type="number"
149
+ min={0}
150
+ placeholder={t('admin.fields.product.lead_time_days.placeholder')}
151
+ aria-invalid={Boolean(error)}
152
+ {...form.register('lead_time_days', {
153
+ // Empty input → null (not NaN, not "") so the API gets a clean value.
154
+ setValueAs: (v) => (v === '' || v == null ? null : Number(v)),
155
+ })}
156
+ />
157
+ {error && <FieldError>{error as string}</FieldError>}
158
+ </Field>
159
+ </CardContent>
160
+ </Card>
161
+ )
162
+ }
163
+ ```
164
+
165
+ ```json
166
+ // src/locales/en.json — note the top-level "admin" wrapper
167
+ {
168
+ "admin": {
169
+ "fields": {
170
+ "product": {
171
+ "lead_time_days": {
172
+ "label": "Lead time (days)",
173
+ "placeholder": "e.g. 14"
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ ```
180
+
181
+ ## 3. Verify it
182
+
183
+ API first — the fastest way to confirm the backend pieces:
184
+
185
+ ```bash
186
+ # write (permitted param + validation)
187
+ npx spree api patch products/prod_xxx --data '{"lead_time_days": 14}'
188
+
189
+ # filter + sort (Ransack allowlist)
190
+ npx spree api get "products?q[lead_time_days_gt]=7&sort=-lead_time_days"
191
+ ```
192
+
193
+ Then the UI: open a product — the Lead time card sits in the sidebar; edit it and the page's Save button arms; save and reload. On the products list the column renders, the header sorts, and the filter panel offers the numeric operators. Filters and sort round-trip through the URL — a filtered view is shareable — and a CSV export of the filtered list respects your filter, because the export stores the same Ransack predicate hash.
194
+
195
+ ## How the pieces map
196
+
197
+ | Frontend behavior | Made possible by |
198
+ |---|---|
199
+ | Form input hydrates + saves with the page | serializer attribute + permitted param + `formFields`/`useHostForm` |
200
+ | Table cell renders | serializer attribute + `ColumnDef.render` |
201
+ | Header sort works | `sortable: true` + Ransack allowlist |
202
+ | Filter panel entry works | `filterable: true` + `filterType` + Ransack allowlist |
203
+ | Inline validation message | the Rails validation (422 → mapped onto the field) |
204
+
205
+ Two `ColumnDef` extras worth knowing: `ransackAttribute` points the predicate somewhere other than the column key (the built-in `sku` column filters through `master_sku`), and `displayable: false` makes a filter-only field that never renders as a column.
206
+
207
+ ## Reference
208
+
209
+ - [Custom form field recipe](custom-form-field.md) — the form layer in isolation, and the custom-field-definition path
210
+ - [Tables](../customization/tables.md) — the full `ColumnDef` and table registry API
211
+ - [Backend integration](../customization/backend.md) — serializers, permitted params, error mapping
212
+ - [Decorators](../../customization/decorators.md) — the `prepend` pattern used for the controller
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: Add a custom field to the product form
3
+ sidebarTitle: Custom form field
4
+ description: Add a field to the built-in product form that hydrates from the API and saves with the form's own Save button — no save logic of your own.
5
+ ---
6
+
7
+ Two ways to get a custom field onto the product form, both saved by the page's single Save button:
8
+
9
+ - **Custom field definition (no code)** — declare the field as data; the dashboard renders it. Right for merchant-managed attributes and most plugin data.
10
+ - **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.
11
+
12
+ ## Path A: custom field definition — no code
13
+
14
+ Create a definition for products (Settings → Custom fields, the in-place "Set up" button on the Custom Fields card, or the API):
15
+
16
+ ```bash
17
+ npx spree api post custom_field_definitions --data '{
18
+ "resource_type": "Spree::Product",
19
+ "namespace": "specs",
20
+ "key": "tech_specs",
21
+ "label": "Technical specifications",
22
+ "field_type": "long_text"
23
+ }'
24
+ ```
25
+
26
+ 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`.
27
+
28
+ ### Optional: replace the input widget
29
+
30
+ 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`:
31
+
32
+ ```tsx
33
+ // src/plugins.ts (or a plugin's entry module)
34
+ import { type CustomFieldComponentProps, defineDashboardPlugin } from '@spree/dashboard-core'
35
+
36
+ function ColorPicker({ id, value, onChange, ariaLabel }: CustomFieldComponentProps) {
37
+ return (
38
+ <input
39
+ id={id}
40
+ type="color"
41
+ aria-label={ariaLabel}
42
+ value={(value as string) || '#000000'}
43
+ onChange={(e) => onChange(e.target.value)}
44
+ />
45
+ )
46
+ }
47
+
48
+ defineDashboardPlugin({
49
+ customFieldComponents: {
50
+ 'specs.color': ColorPicker,
51
+ },
52
+ })
53
+ ```
54
+
55
+ 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.
56
+
57
+ ## Path B: extension form field — a real column
58
+
59
+ Use this when your plugin adds an actual column and API attribute.
60
+
61
+ ### 1. Backend
62
+
63
+ ```ruby
64
+ class AddTechSpecsToSpreeProducts < ActiveRecord::Migration[7.2]
65
+ def change
66
+ add_column :spree_products, :tech_specs, :text, null: false, default: ''
67
+ end
68
+ end
69
+ ```
70
+
71
+ Expose it on the Admin API — the serializer returns `tech_specs`, and the products controller permits it on write (see [Backend integration](../customization/backend.md)). Read and write names must match.
72
+
73
+ ### 2. Register the field
74
+
75
+ 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.
76
+
77
+ ```tsx
78
+ // src/plugins.ts (or a plugin's entry module)
79
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
80
+
81
+ defineDashboardPlugin({
82
+ formFields: {
83
+ product: [
84
+ // `from` receives the fetched product — or null on the create form.
85
+ { name: 'tech_specs', from: (product) => product?.tech_specs ?? '' },
86
+ ],
87
+ },
88
+ })
89
+ ```
90
+
91
+ ### 3. Render the input from a slot
92
+
93
+ 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:
94
+
95
+ ```tsx
96
+ // src/fields/tech-specs-card.tsx
97
+ import { useHostForm } from '@spree/dashboard-core'
98
+ import {
99
+ Card, CardContent, CardHeader, CardTitle,
100
+ Field, FieldError, FieldLabel, Textarea,
101
+ } from '@spree/dashboard-ui'
102
+ import { useTranslation } from 'react-i18next'
103
+
104
+ export function TechSpecsCard() {
105
+ const { t } = useTranslation()
106
+ const form = useHostForm<{ tech_specs: string }>()
107
+ const error = form.formState.errors.tech_specs?.message
108
+
109
+ return (
110
+ <Card>
111
+ <CardHeader>
112
+ <CardTitle>{t('admin.fields.product.tech_specs.label')}</CardTitle>
113
+ </CardHeader>
114
+ <CardContent>
115
+ <Field>
116
+ <FieldLabel className="sr-only" htmlFor="tech_specs">
117
+ {t('admin.fields.product.tech_specs.label')}
118
+ </FieldLabel>
119
+ <Textarea
120
+ id="tech_specs"
121
+ rows={6}
122
+ placeholder={t('admin.fields.product.tech_specs.placeholder')}
123
+ aria-invalid={Boolean(error)}
124
+ {...form.register('tech_specs')}
125
+ />
126
+ {error && <FieldError>{error as string}</FieldError>}
127
+ </Field>
128
+ </CardContent>
129
+ </Card>
130
+ )
131
+ }
132
+ ```
133
+
134
+ ```tsx
135
+ // src/plugins.ts — alongside the formFields registration
136
+ defineDashboardPlugin({
137
+ formFields: {
138
+ product: [{ name: 'tech_specs', from: (product) => product?.tech_specs ?? '' }],
139
+ },
140
+ slots: {
141
+ 'product.form_sidebar': [{ id: 'tech-specs', component: TechSpecsCard as never, position: 80 }],
142
+ },
143
+ })
144
+ ```
145
+
146
+ 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.
147
+
148
+ ### Validation
149
+
150
+ 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.
151
+
152
+ ### Which forms support this
153
+
154
+ `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.
155
+
156
+ ## Which path should I pick?
157
+
158
+ | Situation | Path |
159
+ |---|---|
160
+ | Merchant-defined attributes, plugin data without schema migrations | **A** — definition, zero code |
161
+ | Default widget is fine | **A** |
162
+ | A real column with Rails validations, queries, or indexes | **B** |
163
+ | The field belongs visually inside your own card with other UI | **B** |
164
+
165
+ ## Going further
166
+
167
+ To also surface the attribute as a **table column with filtering and sorting**, continue with [A product attribute, end to end](attribute-end-to-end.md).
168
+
169
+ ## Reference
170
+
171
+ - [Custom fields](../../customization/metadata.md) — definitions, types, the API
172
+ - [Slots](../customization/slots.md) — the injection mechanism
173
+ - [`useHostForm`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-host-form.ts)
174
+ - [`formFields` registry](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/form-fields-registry.ts)