@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.
- package/dist/api-reference/admin-api/endpoints.md +1 -1
- package/dist/developer/cli/admin-api.md +11 -0
- package/dist/developer/dashboard/concepts.md +103 -0
- package/dist/developer/dashboard/customization/backend.md +126 -0
- package/dist/developer/dashboard/customization/navigation.md +211 -0
- package/dist/developer/dashboard/customization/permissions.md +123 -0
- package/dist/developer/dashboard/customization/quickstart.md +100 -0
- package/dist/developer/dashboard/customization/routes.md +187 -0
- package/dist/developer/dashboard/customization/slots.md +114 -0
- package/dist/developer/dashboard/customization/tables.md +167 -0
- package/dist/developer/dashboard/customization/translations.md +96 -0
- package/dist/developer/dashboard/deployment.md +80 -0
- package/dist/developer/dashboard/overview.md +105 -0
- package/dist/developer/dashboard/plugins/distributing.md +137 -0
- package/dist/developer/dashboard/plugins/overview.md +46 -0
- package/dist/developer/dashboard/plugins/publishing.md +142 -0
- package/dist/developer/dashboard/plugins/scaffolding.md +133 -0
- package/dist/developer/dashboard/public-api.md +241 -0
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +212 -0
- package/dist/developer/dashboard/recipes/custom-form-field.md +174 -0
- package/dist/developer/dashboard/recipes/page-action-button.md +189 -0
- package/dist/developer/dashboard/recipes/sidebar-widget.md +147 -0
- package/dist/developer/dashboard/slots-catalog.md +173 -0
- package/dist/developer/sdk/admin/extending.md +70 -0
- package/dist/developer/sdk/admin/quickstart.md +3 -3
- package/dist/developer/tutorial/admin.md +2 -0
- package/dist/developer/tutorial/api.md +2 -0
- package/dist/developer/tutorial/extending-models.md +2 -0
- package/dist/developer/tutorial/model.md +2 -0
- 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)
|