@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,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add a page action button
|
|
3
|
+
sidebarTitle: Page action button
|
|
4
|
+
description: Add a primary action (e.g., "Send invoice", "Sync to ERP") to a built-in detail page's header — both as a top-right button and as a dropdown item.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Built-in detail pages render `<PageHeader>` at the top with primary action buttons on the right and a more-actions (`⋯`) dropdown next to them. Both are slot-based — you can inject your own buttons or menu items without touching the host page.
|
|
8
|
+
|
|
9
|
+
Two slots cover the surface:
|
|
10
|
+
|
|
11
|
+
- `page.actions` — the right-aligned action zone (buttons, badges)
|
|
12
|
+
- `page.actions_dropdown` — the dropdown menu
|
|
13
|
+
|
|
14
|
+
This recipe shows both — and how to gate them by permission + resource state. (Strings are hardcoded for brevity; real buttons should go through i18n — see [Translations](../customization/translations.md).)
|
|
15
|
+
|
|
16
|
+
## A button in the actions row
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// src/actions/send-invoice-button.tsx
|
|
20
|
+
import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
|
|
21
|
+
import { Button } from '@spree/dashboard-ui'
|
|
22
|
+
import { Send } from 'lucide-react'
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
resource: { id: string; state: string } // Order
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SendInvoiceButton({ resource }: Props) {
|
|
29
|
+
const { permissions } = usePermissions()
|
|
30
|
+
const mutation = useResourceMutation({
|
|
31
|
+
mutationFn: () =>
|
|
32
|
+
adminClient.request('POST', `/orders/${resource.id}/send_invoice`),
|
|
33
|
+
successMessage: 'Invoice sent',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Only show to users who can act, and only on completed orders.
|
|
37
|
+
if (!permissions.can('update', 'Spree::Order')) return null
|
|
38
|
+
if (resource.state !== 'complete') return null
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Button
|
|
42
|
+
variant="outline"
|
|
43
|
+
size="sm"
|
|
44
|
+
onClick={() => mutation.mutate()}
|
|
45
|
+
disabled={mutation.isPending}
|
|
46
|
+
>
|
|
47
|
+
<Send className="size-4" />
|
|
48
|
+
Send invoice
|
|
49
|
+
</Button>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Register it:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// src/plugins.ts (in your dashboard app)
|
|
58
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
59
|
+
import { SendInvoiceButton } from './actions/send-invoice-button'
|
|
60
|
+
|
|
61
|
+
defineDashboardPlugin({
|
|
62
|
+
slots: {
|
|
63
|
+
'page.actions': [{
|
|
64
|
+
id: 'send-invoice',
|
|
65
|
+
component: SendInvoiceButton as never,
|
|
66
|
+
position: 50,
|
|
67
|
+
}],
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Two layers of gating, both inside the component:
|
|
73
|
+
|
|
74
|
+
1. **Permission check** — `usePermissions()`; render `null` when the user can't act.
|
|
75
|
+
2. **Resource-state check** — the user *can* send invoices, but this specific order isn't ready yet.
|
|
76
|
+
|
|
77
|
+
(The slot entry's `if` predicate receives only the slot's own context — `{ resource }` here — so use it for resource-state conditions, and keep permission checks in the component. See [Slots](../customization/slots.md).)
|
|
78
|
+
|
|
79
|
+
## A dropdown menu item
|
|
80
|
+
|
|
81
|
+
For secondary actions, use `page.actions_dropdown` and render `<DropdownMenuItem>`:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// src/actions/sync-to-erp-item.tsx
|
|
85
|
+
import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
|
|
86
|
+
import { DropdownMenuItem } from '@spree/dashboard-ui'
|
|
87
|
+
import { RefreshCw } from 'lucide-react'
|
|
88
|
+
|
|
89
|
+
interface Props {
|
|
90
|
+
resource: { id: string }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function SyncToErpItem({ resource }: Props) {
|
|
94
|
+
const { permissions } = usePermissions()
|
|
95
|
+
const mutation = useResourceMutation({
|
|
96
|
+
mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/sync_to_erp`),
|
|
97
|
+
successMessage: 'Synced',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!permissions.can('manage', 'Spree::Order')) return null
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<DropdownMenuItem
|
|
104
|
+
onSelect={(event) => {
|
|
105
|
+
event.preventDefault()
|
|
106
|
+
mutation.mutate()
|
|
107
|
+
}}
|
|
108
|
+
disabled={mutation.isPending}
|
|
109
|
+
>
|
|
110
|
+
<RefreshCw className="size-4" />
|
|
111
|
+
Sync to ERP
|
|
112
|
+
</DropdownMenuItem>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
defineDashboardPlugin({
|
|
119
|
+
slots: {
|
|
120
|
+
'page.actions_dropdown': [{
|
|
121
|
+
id: 'sync-to-erp',
|
|
122
|
+
component: SyncToErpItem as never,
|
|
123
|
+
position: 50,
|
|
124
|
+
}],
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `event.preventDefault()` keeps the dropdown open if the mutation fails — without it, the menu closes on click and the toast appears with no context.
|
|
130
|
+
|
|
131
|
+
## Confirm before acting
|
|
132
|
+
|
|
133
|
+
For destructive or expensive actions, wrap the click in a `<Dialog>`:
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { useState } from 'react'
|
|
137
|
+
import {
|
|
138
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
139
|
+
DropdownMenuItem, Button,
|
|
140
|
+
} from '@spree/dashboard-ui'
|
|
141
|
+
|
|
142
|
+
export function ResetCartItem({ resource }: { resource: { id: string } }) {
|
|
143
|
+
const [open, setOpen] = useState(false)
|
|
144
|
+
const mutation = useResourceMutation({
|
|
145
|
+
mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/reset_cart`),
|
|
146
|
+
successMessage: 'Cart reset',
|
|
147
|
+
onSuccess: () => setOpen(false),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<DropdownMenuItem
|
|
153
|
+
onSelect={(event) => {
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
setOpen(true)
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
Reset cart
|
|
159
|
+
</DropdownMenuItem>
|
|
160
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
161
|
+
<DialogContent>
|
|
162
|
+
<DialogHeader>
|
|
163
|
+
<DialogTitle>Reset this cart?</DialogTitle>
|
|
164
|
+
</DialogHeader>
|
|
165
|
+
<p>All line items will be cleared. This cannot be undone.</p>
|
|
166
|
+
<DialogFooter>
|
|
167
|
+
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
|
168
|
+
<Button variant="destructive" onClick={() => mutation.mutate()}>
|
|
169
|
+
Reset
|
|
170
|
+
</Button>
|
|
171
|
+
</DialogFooter>
|
|
172
|
+
</DialogContent>
|
|
173
|
+
</Dialog>
|
|
174
|
+
</>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Context
|
|
180
|
+
|
|
181
|
+
The `resource` prop is whatever the host page passed to `<PageHeader resource={…} />`. On the order detail page it's an `Order`; on a product page it's a `Product`. The slot doesn't statically know which — see [Slots](../customization/slots.md) for the typing escape hatches.
|
|
182
|
+
|
|
183
|
+
For the current store, user, or permissions, call the hooks inside your component — `useStore()`, `useAuth()`, `usePermissions()` from `@spree/dashboard-core`.
|
|
184
|
+
|
|
185
|
+
## Reference
|
|
186
|
+
|
|
187
|
+
- [`page.actions` / `page.actions_dropdown`](../slots-catalog.md#page-header-slots) in the slots catalog
|
|
188
|
+
- [`useResourceMutation`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-resource-mutation.ts) — 422-aware mutation hook
|
|
189
|
+
- [Slots](../customization/slots.md) — registry primer
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add a sidebar widget
|
|
3
|
+
sidebarTitle: Sidebar widget
|
|
4
|
+
description: Add an info card to a built-in detail page's sidebar — pulling its own data, gated by permissions, with loading and empty states.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The product, order, and customer detail pages each render a `<Slot>` in their right-hand sidebar — typically named `<resource>.form_sidebar`. Widgets rendered into these slots see the current record and can fetch their own data without disturbing the host page.
|
|
8
|
+
|
|
9
|
+
This recipe builds a "Loyalty status" card for the customer detail page: read-only, fetches the customer's points + tier, shows a spinner while loading, an empty state when there's no data, and hides itself for staff without the right permission.
|
|
10
|
+
|
|
11
|
+
## The component
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
// src/widgets/loyalty-status-card.tsx
|
|
15
|
+
import { useQuery } from '@tanstack/react-query'
|
|
16
|
+
import { adminClient, usePermissions, useStore } from '@spree/dashboard-core'
|
|
17
|
+
import {
|
|
18
|
+
Badge,
|
|
19
|
+
Card, CardContent, CardHeader, CardTitle,
|
|
20
|
+
Empty, EmptyDescription,
|
|
21
|
+
Skeleton,
|
|
22
|
+
} from '@spree/dashboard-ui'
|
|
23
|
+
import { Trophy } from 'lucide-react'
|
|
24
|
+
|
|
25
|
+
interface LoyaltyRecord {
|
|
26
|
+
customer_id: string
|
|
27
|
+
points: number
|
|
28
|
+
tier: 'bronze' | 'silver' | 'gold'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
// The customer detail page's slot context — see the slots catalog.
|
|
33
|
+
customer: { id: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function LoyaltyStatusCard({ customer }: Props) {
|
|
37
|
+
const { storeId } = useStore()
|
|
38
|
+
const { permissions } = usePermissions()
|
|
39
|
+
const canRead = permissions.can('read', 'MyApp::LoyaltyRecord')
|
|
40
|
+
const { data, isLoading } = useQuery({
|
|
41
|
+
queryKey: ['loyalty', storeId, customer.id],
|
|
42
|
+
queryFn: () =>
|
|
43
|
+
adminClient.request<{ data: LoyaltyRecord | null }>(
|
|
44
|
+
'GET',
|
|
45
|
+
`/customers/${customer.id}/loyalty`,
|
|
46
|
+
),
|
|
47
|
+
staleTime: 30_000,
|
|
48
|
+
enabled: canRead,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Hide entirely for staff without the permission — same behavior as
|
|
52
|
+
// core's permission-gated cards.
|
|
53
|
+
if (!canRead) return null
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card>
|
|
57
|
+
<CardHeader>
|
|
58
|
+
<CardTitle className="flex items-center gap-2">
|
|
59
|
+
<Trophy className="size-4" />
|
|
60
|
+
Loyalty status
|
|
61
|
+
</CardTitle>
|
|
62
|
+
</CardHeader>
|
|
63
|
+
<CardContent>
|
|
64
|
+
{isLoading ? (
|
|
65
|
+
<Skeleton className="h-8 w-32" />
|
|
66
|
+
) : !data?.data ? (
|
|
67
|
+
<Empty>
|
|
68
|
+
<EmptyDescription>No loyalty record</EmptyDescription>
|
|
69
|
+
</Empty>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<Badge variant={tierVariant(data.data.tier)}>{data.data.tier}</Badge>
|
|
73
|
+
<p className="text-sm text-muted-foreground">{data.data.points} points</p>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</CardContent>
|
|
77
|
+
</Card>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tierVariant(tier: LoyaltyRecord['tier']) {
|
|
82
|
+
return tier === 'gold' ? 'default' : 'secondary'
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Worth noting:
|
|
87
|
+
|
|
88
|
+
- **`storeId` in the query key.** The dashboard is multi-store; without this, switching stores leaves stale data on screen.
|
|
89
|
+
- **`staleTime: 30_000`** keeps the data fresh enough for a sidebar widget without re-fetching on every focus event.
|
|
90
|
+
- **Three states.** Loading (`<Skeleton>`), empty (`<Empty>`), and loaded. Skipping the empty state ships a card that says "0 points" when really the customer has never been enrolled — different meaning, different UX.
|
|
91
|
+
- **Strings are hardcoded for brevity.** Real widgets should go through i18n — see [Translations](../customization/translations.md).
|
|
92
|
+
|
|
93
|
+
## Register the widget
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// src/plugins.ts (in your dashboard app)
|
|
97
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
98
|
+
import { LoyaltyStatusCard } from './widgets/loyalty-status-card'
|
|
99
|
+
|
|
100
|
+
defineDashboardPlugin({
|
|
101
|
+
slots: {
|
|
102
|
+
'customer.form_sidebar': [{
|
|
103
|
+
id: 'loyalty-status',
|
|
104
|
+
component: LoyaltyStatusCard as never,
|
|
105
|
+
position: 50,
|
|
106
|
+
}],
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The permission gate lives *inside* the component (`usePermissions`) rather than in an `if` predicate on the entry — the predicate only receives the slot context today, not ambient permissions (see the [slots catalog](../slots-catalog.md#ambient-context)).
|
|
112
|
+
|
|
113
|
+
`position: 50` puts the card near the top of the sidebar (built-in cards typically use 100/200/300). Pick a number based on whether your widget is more or less important than the others — for a "status" card, near the top is right.
|
|
114
|
+
|
|
115
|
+
## When to fetch in the widget vs. read from the host
|
|
116
|
+
|
|
117
|
+
The pattern above fetches its own data. The alternative is to read it from the resource the host passes:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
function MyCard({ customer }: { customer: Customer }) {
|
|
121
|
+
return <div>{customer.loyalty_points}</div> // host already loaded it
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Choose based on **whether the data is hot path for the rest of the page**:
|
|
126
|
+
|
|
127
|
+
| Data | Approach |
|
|
128
|
+
|---|---|
|
|
129
|
+
| Already on the host's serializer (e.g., `customer.tags`) | Read from the slot context — no extra HTTP |
|
|
130
|
+
| Conditional, expensive, or owned by your plugin | Fetch in the widget |
|
|
131
|
+
| Sometimes-needed (e.g., gated by feature flag) | Fetch in the widget |
|
|
132
|
+
|
|
133
|
+
A widget that always fetches is wasteful when the host already has the data. A widget that reads from the host but the host doesn't serialize is broken. Match the pattern to the source of truth.
|
|
134
|
+
|
|
135
|
+
## Loading states for the host page
|
|
136
|
+
|
|
137
|
+
The built-in detail pages render their sidebar only after the record has loaded, so the context record is always set when your widget mounts. If you register into a slot where that's not guaranteed (check the slots catalog entry), guard defensively:
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
if (!customer) return null // or a <Skeleton> to reserve layout space
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Reference
|
|
144
|
+
|
|
145
|
+
- [Slots catalog → form sidebars](../slots-catalog.md) — every page-level sidebar slot
|
|
146
|
+
- [`useQuery`](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery) — query key conventions
|
|
147
|
+
- [`@spree/dashboard` README](https://github.com/spree/spree/blob/main/packages/dashboard/README.md) — the multi-store model and why `storeId` belongs in the key
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Slots catalog
|
|
3
|
+
sidebarTitle: Slots catalog
|
|
4
|
+
description: Every named slot the dashboard renders today. Each entry lists the host page, the slot name, and the context the slot's components receive.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This is the canonical list of slots the dashboard currently exposes. The source of truth is the `<Slot name="…">` call sites in the dashboard source (linked under Reference below); each entry here records the host page, the slot name, and the context your component receives.
|
|
8
|
+
|
|
9
|
+
If you need a new injection point in a built-in page, open a PR adding `<Slot name="..." context={...} />` and a documentation entry here — that's the contract for new slots.
|
|
10
|
+
|
|
11
|
+
## Ambient context
|
|
12
|
+
|
|
13
|
+
Ambient context (`permissions`, `store`, `user` merged into every slot's props) is planned but **not wired up yet** — today slot components receive only the slot-specific context listed below. Until it lands, read those values with the hooks instead:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { usePermissions, useStore } from '@spree/dashboard-core'
|
|
17
|
+
|
|
18
|
+
function MyWidget({ product }: { product: Product }) {
|
|
19
|
+
const { permissions } = usePermissions()
|
|
20
|
+
const { store } = useStore()
|
|
21
|
+
if (!permissions.can('read', 'MyApp::LoyaltyRecord')) return null
|
|
22
|
+
// ...
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The same applies to an entry's `if` predicate: it receives the slot context, but not `permissions` — gate inside the component for now.
|
|
27
|
+
|
|
28
|
+
## Page header slots
|
|
29
|
+
|
|
30
|
+
Rendered by `<PageHeader>` (`@spree/dashboard-core/components/page-header.tsx`) at the top of every detail and list page that uses the shared chrome.
|
|
31
|
+
|
|
32
|
+
### `page.actions`
|
|
33
|
+
|
|
34
|
+
| Where | Top-right of `<PageHeader>`, left of any explicit `actions` prop |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Use | Add primary action buttons (e.g., a "Refund" button on the order detail page) |
|
|
37
|
+
| Context | `{ resource, ...slotContext }` — `resource` is the current page's primary record, when the host passed one to `<PageHeader resource={...} />`. May be `undefined` on list pages. |
|
|
38
|
+
|
|
39
|
+
### `page.actions_dropdown`
|
|
40
|
+
|
|
41
|
+
| Where | Inside the more-actions (`⋯`) dropdown menu, above the auto-rendered Copy ID / Delete items |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Use | Add menu items (`<DropdownMenuItem>`) for secondary actions |
|
|
44
|
+
| Context | `{ resource, ...slotContext }` — same as `page.actions` |
|
|
45
|
+
|
|
46
|
+
## Detail-page form slots
|
|
47
|
+
|
|
48
|
+
The resource detail pages each render a slot below their built-in cards. The context key matches the resource name.
|
|
49
|
+
|
|
50
|
+
**Host form:** on pages marked *host form: yes*, the slot renders inside the page's own `<form>` and the form context is exposed — widgets can bind inputs via [`useHostForm()`](recipes/custom-form-field.md) that hydrate, dirty-track, and save with the page's Save button. On pages without one, widgets own their persistence (use `useOptionalHostForm()` to adapt).
|
|
51
|
+
|
|
52
|
+
### `product.form_sidebar`
|
|
53
|
+
|
|
54
|
+
| Where | Product detail page (`products/$productId`), end of the sidebar column |
|
|
55
|
+
|---|---|
|
|
56
|
+
| Use | Add a card scoped to the product being edited (e.g., a "Brand" picker, wishlist stats, sync status from an external system) |
|
|
57
|
+
| Context | `{ product }` — the full `Product` record from the Admin API |
|
|
58
|
+
| Host form | **Yes** — form key `product` |
|
|
59
|
+
|
|
60
|
+
### `category.form_sidebar`
|
|
61
|
+
|
|
62
|
+
| Where | Category detail page (`products/categories/$categoryId`), end of the sidebar column |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Use | Category-scoped cards — merchandising rules, feed settings, external sync state |
|
|
65
|
+
| Context | `{ category }` — the `Category` record (may be briefly `undefined` while refetching) |
|
|
66
|
+
| Host form | **Yes** — form key `category` |
|
|
67
|
+
|
|
68
|
+
### `store.form_main`
|
|
69
|
+
|
|
70
|
+
| Where | Store settings page (`settings/store`), end of the main column |
|
|
71
|
+
|---|---|
|
|
72
|
+
| Use | Store-level settings a plugin owns — integration toggles, account linking |
|
|
73
|
+
| Context | `{ store }` — the full `Store` record |
|
|
74
|
+
| Host form | **Yes** — form key `store` |
|
|
75
|
+
|
|
76
|
+
### `order.form_sidebar`
|
|
77
|
+
|
|
78
|
+
| Where | Order detail page (`orders/$orderId`), end of the sidebar column |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Use | Fraud-check status, shipping-integration state, loyalty points earned |
|
|
81
|
+
| Context | `{ order }` — the full `Order` record |
|
|
82
|
+
| Host form | No — widgets save via their own API calls |
|
|
83
|
+
|
|
84
|
+
### `customer.form_sidebar`
|
|
85
|
+
|
|
86
|
+
| Where | Customer detail page (`customers/$customerId`), end of the sidebar column |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Use | Loyalty status, support-ticket summary, external CRM links |
|
|
89
|
+
| Context | `{ customer }` — the full `Customer` record |
|
|
90
|
+
| Host form | No — the page edits through sheets; widgets save via their own API calls |
|
|
91
|
+
|
|
92
|
+
## Page tabs slot
|
|
93
|
+
|
|
94
|
+
### `page.tabs` (default)
|
|
95
|
+
|
|
96
|
+
Rendered by `<PageTabs>` at the right edge of any tabbed sub-nav.
|
|
97
|
+
|
|
98
|
+
| Where | After the built-in tab strip |
|
|
99
|
+
|---|---|
|
|
100
|
+
| Use | Append your own tab(s) to a sub-route — typically for plugin-owned views inside an existing resource (e.g., "Returns" tab on the order detail) |
|
|
101
|
+
| Context | `{ tabs, ...slotContext }` — `tabs` is the array of built-in tabs (so you can inspect or filter by them) |
|
|
102
|
+
|
|
103
|
+
Some pages override `slotName` to scope tabs to a single resource (e.g., `slotName="order.tabs"`). The catalog will grow these as we wire them up; today only the default `page.tabs` name is in production use.
|
|
104
|
+
|
|
105
|
+
## Payment method editor slots (dynamic)
|
|
106
|
+
|
|
107
|
+
Used by `<PaymentMethodForm>` to let payment-provider plugins replace pieces of the editor sheet. The slot names are computed per provider type (`stripe`, `bogus`, …), so registering against the right name is what hooks your editor in.
|
|
108
|
+
|
|
109
|
+
### `payment_method.guide.<providerType>`
|
|
110
|
+
|
|
111
|
+
| Where | Above the preferences form |
|
|
112
|
+
|---|---|
|
|
113
|
+
| Use | Banner explaining what the integration does, what credentials to use, links to provider docs |
|
|
114
|
+
| Context | `PaymentMethodEditorContext` (see below) |
|
|
115
|
+
|
|
116
|
+
### `payment_method.form.<providerType>`
|
|
117
|
+
|
|
118
|
+
| Where | In place of the auto-generated preferences form |
|
|
119
|
+
|---|---|
|
|
120
|
+
| Use | Render a custom React form when the provider's preferences are too complex for the generated UI (multi-step OAuth, environment switchers, …) |
|
|
121
|
+
| Context | `PaymentMethodEditorContext` |
|
|
122
|
+
|
|
123
|
+
### `payment_method.actions.<providerType>`
|
|
124
|
+
|
|
125
|
+
| Where | Sheet footer, before Save/Cancel |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Use | Provider-specific action buttons — "Test connection", "Rotate keys", "Open dashboard in provider" |
|
|
128
|
+
| Context | `PaymentMethodEditorContext` |
|
|
129
|
+
|
|
130
|
+
### `PaymentMethodEditorContext`
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
interface PaymentMethodEditorContext {
|
|
134
|
+
mode: 'create' | 'edit'
|
|
135
|
+
type: string // STI shorthand (`stripe`, `bogus`, …)
|
|
136
|
+
paymentMethod: PaymentMethod | null // null in create mode
|
|
137
|
+
preferenceSchema: PreferenceField[]
|
|
138
|
+
preferences: Record<string, unknown>
|
|
139
|
+
onPreferencesChange: (next: Record<string, unknown>) => void
|
|
140
|
+
form: UseFormReturn<PaymentMethodFormValues> // RHF instance for top-level fields
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Helpers for building the slot name:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import {
|
|
148
|
+
paymentMethodGuideSlot,
|
|
149
|
+
paymentMethodFormSlot,
|
|
150
|
+
paymentMethodActionsSlot,
|
|
151
|
+
} from '@spree/dashboard/components/spree/payment-method-editors/types'
|
|
152
|
+
|
|
153
|
+
paymentMethodFormSlot('stripe') // → "payment_method.form.stripe"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Use these instead of constructing the string yourself, so a rename in one place doesn't silently break your registration.
|
|
157
|
+
|
|
158
|
+
## Adding a new slot
|
|
159
|
+
|
|
160
|
+
If a built-in page should expose a new injection point:
|
|
161
|
+
|
|
162
|
+
1. Pick a name (`<resource>.<area>`, e.g., `order.timeline`)
|
|
163
|
+
2. Add `<Slot name="..." context={{ resource: order, /* ... */ }} />` at the call site
|
|
164
|
+
3. Document the slot here — host, intent, context shape
|
|
165
|
+
4. Open the PR
|
|
166
|
+
|
|
167
|
+
The slot is not "live" until the docs land — without an entry here, no plugin author knows it exists.
|
|
168
|
+
|
|
169
|
+
## Reference
|
|
170
|
+
|
|
171
|
+
- [`<Slot>` source](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/components/slot.tsx)
|
|
172
|
+
- [`slot-registry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/slot-registry.ts) — `registerSlot`, `removeSlot`, `updateSlot`, `useSlotEntries`
|
|
173
|
+
- [Slots customization page](customization/slots.md) — how to register against a slot
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Custom Admin Endpoints
|
|
3
|
+
sidebarTitle: Custom Endpoints
|
|
4
|
+
description: Call custom Admin API endpoints using the SDK's built-in request method
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The Admin SDK client exposes a `request` method — the same function that powers `client.products.list()`, `client.orders.get()`, and every other built-in resource. Use it to call any Admin API endpoint, including ones added by an extension gem that doesn't ship its own client.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { createAdminClient } from '@spree/admin-sdk'
|
|
11
|
+
import type { PaginatedResponse } from '@spree/admin-sdk'
|
|
12
|
+
|
|
13
|
+
const client = createAdminClient({
|
|
14
|
+
baseUrl: 'https://api.mystore.com',
|
|
15
|
+
token: 'sk_YOUR_SECRET_KEY', // or a JWT from /auth/login
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
interface Brand {
|
|
19
|
+
id: string
|
|
20
|
+
name: string
|
|
21
|
+
slug: string | null
|
|
22
|
+
description: string | null
|
|
23
|
+
logo_url: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Paths are relative to /api/v3/admin
|
|
27
|
+
const brands = await client.request<PaginatedResponse<Brand>>('GET', '/brands')
|
|
28
|
+
const brand = await client.request<Brand>('GET', '/brands/brand_2X9aQf7kEw')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`client.request` uses the same auth headers (secret API key *or* JWT, whichever was configured at client creation), retry logic, and base URL as every other resource on the client. The 401 → refresh → retry handshake that's wired up for the dashboard's user session applies to your custom endpoint too.
|
|
32
|
+
|
|
33
|
+
## Mutations
|
|
34
|
+
|
|
35
|
+
POST/PATCH/DELETE work the same way — pass `{ body }` for the request payload:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const created = await client.request<Brand>('POST', '/brands', {
|
|
39
|
+
body: { name: 'Acme', slug: 'acme', description: 'Quality goods since 1953' },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const updated = await client.request<Brand>('PATCH', `/brands/${created.id}`, {
|
|
43
|
+
body: { description: 'New tagline' },
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
await client.request<void>('DELETE', `/brands/${created.id}`)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Ransack filters and pagination
|
|
50
|
+
|
|
51
|
+
Standard list params work — pass them as the third argument's `params`:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const result = await client.request<PaginatedResponse<Brand>>('GET', '/brands', {
|
|
55
|
+
params: {
|
|
56
|
+
page: 2,
|
|
57
|
+
limit: 25,
|
|
58
|
+
filter: { name_cont: 'nike' },
|
|
59
|
+
sort: '-created_at',
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The SDK transforms `filter`/`sort` into the Ransack-flavored query string the Admin API expects (`q[name_cont]=nike&q[s]=created_at%20desc`) automatically — same behavior as `client.products.list({ filter: ... })`.
|
|
65
|
+
|
|
66
|
+
## See also
|
|
67
|
+
|
|
68
|
+
- The [Store SDK extending guide](../extending.md) covers the same pattern for storefront endpoints — the API is identical, only the base path (`/api/v3/store`) and auth header differ.
|
|
69
|
+
- The [API](../../tutorial/api.md) and [SDK](../../tutorial/sdk.md) tutorials walk through creating new Spree endpoints end-to-end (Rails controller, serializer, TypeScript types). Everything they show for the Store API applies equivalently for the Admin API.
|
|
70
|
+
- For building dashboard UI on top of your custom endpoint, see the [Dashboard](../../dashboard/overview.md) section — start with [Customization Quickstart](../../dashboard/customization/quickstart.md) for in-app changes or [Plugin Overview](../../dashboard/plugins/overview.md) for redistributable packages.
|
|
@@ -47,12 +47,12 @@ const product = await client.products.create({
|
|
|
47
47
|
})
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Every method is fully typed — responses use
|
|
50
|
+
Every method is fully typed — responses use types generated from the API serializers:
|
|
51
51
|
|
|
52
52
|
```typescript
|
|
53
|
-
import type {
|
|
53
|
+
import type { Order, PaginatedResponse } from '@spree/admin-sdk'
|
|
54
54
|
|
|
55
|
-
const page: PaginatedResponse<
|
|
55
|
+
const page: PaginatedResponse<Order> = await client.orders.list()
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
> **NOTE:** Secret keys grant back-office access — never ship them in browser code. For browser-based admin apps, use [JWT cookie authentication](authentication.md#jwt--cookie-authentication-browser-apps) instead.
|
|
@@ -3,6 +3,8 @@ title: Admin Dashboard
|
|
|
3
3
|
description: Scaffold the Spree admin UI for the Brands resource and add a rich text description editor, an Active Storage logo upload, and a custom table column.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
> **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
|
|
7
|
+
|
|
6
8
|
Now that we've created the `Brand` model, let's create an Admin Dashboard interface so admins can manage brands — including editing the rich text description and uploading the logo.
|
|
7
9
|
|
|
8
10
|
## Step 1: Scaffold the Admin UI
|
|
@@ -4,6 +4,8 @@ sidebarTitle: API
|
|
|
4
4
|
description: Expose your custom Brand model through the Spree Store and Admin REST APIs with serializers, controllers, routes, and the spree:api_resource generator.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
> **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
|
|
8
|
+
|
|
7
9
|
In this tutorial, we'll expose our Brand model through Spree's v3 API — the customer-facing **Store API** that storefronts read from, and the back-office **Admin API** with full CRUD for apps and integrations. We'll also extend the existing Product serializer to include brand data.
|
|
8
10
|
|
|
9
11
|
> **INFO:** This guide assumes you've completed the [Model](model.md), [Admin](admin.md), and [Extending Core Models](extending-models.md) tutorials.
|
|
@@ -3,6 +3,8 @@ title: Extending Core Models
|
|
|
3
3
|
description: Tutorial — extend Spree's core models with a custom Brand resource, generate a migration, and associate it with Products via a decorator.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
> **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
|
|
7
|
+
|
|
6
8
|
In this tutorial, we'll connect our custom Brand model with Spree's core Product model. This is a common pattern when building features that need to integrate with existing Spree functionality.
|
|
7
9
|
|
|
8
10
|
> **INFO:** This guide assumes you've completed the [Model](model.md) and [Admin](admin.md) tutorials.
|
|
@@ -3,6 +3,8 @@ title: Model
|
|
|
3
3
|
description: Create a custom Brand model in Spree with database columns, a rich text description, and an Active Storage logo upload using a single generator command.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
> **NOTE:** This is a backend customization. You need to perform it in the `backend` directory if you used `create-spree-app` to set up your Spree application.
|
|
7
|
+
|
|
6
8
|
In this step we'll create the `Spree::Brand` model with everything it needs: a `name` column, a rich text `description`, and an uploadable `logo`.
|
|
7
9
|
|
|
8
10
|
## Step 1: Generate the Model
|