@spree/docs 0.1.122 → 0.1.123
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/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
|
@@ -366,7 +366,7 @@ Endpoints marked — are exempt from scope checks (authentication and session en
|
|
|
366
366
|
|
|
367
367
|
| Method | Path | Required scope | Summary |
|
|
368
368
|
|---|---|---|---|
|
|
369
|
-
| `POST` | `/translations/batch` |
|
|
369
|
+
| `POST` | `/translations/batch` | `write_*` (eg. `write_products`) for every resource type in the batch (for API-key authentication) | Batch upsert translations |
|
|
370
370
|
|
|
371
371
|
## Webhook endpoints
|
|
372
372
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Concepts
|
|
3
|
+
sidebarTitle: Concepts
|
|
4
|
+
description: The mental model — what the dashboard is built on, how the pieces fit together, and what you need to know about each layer before you start customizing.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This page is a five-minute orientation for the dashboard's technology choices and how they cooperate. If you're already familiar with React + TanStack + Tailwind, skim it for the Spree-specific bits and move on.
|
|
8
|
+
|
|
9
|
+
## Tech stack at a glance
|
|
10
|
+
|
|
11
|
+
| Layer | What we use | Why it matters |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Build | [Vite](https://vitejs.dev/) | Fast HMR, dev server, source consumption of TS/TSX |
|
|
14
|
+
| UI runtime | [React 19](https://react.dev/) | Components, hooks, suspense |
|
|
15
|
+
| Routing | [TanStack Router](https://tanstack.com/router) (file-based) | Type-safe URLs, nested layouts, search params |
|
|
16
|
+
| Data | [TanStack Query v5](https://tanstack.com/query) | Caching, refetch, mutation tracking |
|
|
17
|
+
| Forms | [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) | Uncontrolled inputs + schema validation |
|
|
18
|
+
| Styling | [Tailwind v4](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) + [Base UI](https://base-ui.com/) | Utility classes + headless primitives |
|
|
19
|
+
| i18n | [i18next](https://www.i18next.com/) | Translations, fallback chains |
|
|
20
|
+
| Lint/format | [Biome](https://biomejs.dev/) | ESLint + Prettier in one tool |
|
|
21
|
+
| HTTP | [`@spree/admin-sdk`](https://www.npmjs.com/package/@spree/admin-sdk) | Typed Admin API client |
|
|
22
|
+
|
|
23
|
+
The dashboard is a **Single Page Application**: one HTML page, all routing client-side. No Rails views, no Turbo, no server-rendered partials. Everything the user sees comes from React components fetching JSON from `/api/v3/admin/*`.
|
|
24
|
+
|
|
25
|
+
## The three-package split
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
@spree/dashboard-ui ─ design system: shadcn primitives + headless compounds + tokens
|
|
29
|
+
@spree/dashboard-core ─ framework: registries + providers + hooks + SDK client + plugin facade
|
|
30
|
+
@spree/dashboard ─ app shell: routes, resource hooks, locales, vite config
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
[Overview](overview.md) covers the split in more detail. The short version: **`-ui`** has no providers or hooks (data comes via props); **`-core`** is the extension API and runtime services; **`-dashboard`** is the deployable app shell. Plugins and customizations consume `-core` and `-ui`, never the app shell.
|
|
34
|
+
|
|
35
|
+
## How customization plugs in
|
|
36
|
+
|
|
37
|
+
Five global, in-memory registries hold the things you add or modify:
|
|
38
|
+
|
|
39
|
+
| Registry | Adds | Surfaced by |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `nav` | Sidebar entries | `<AppSidebar>` |
|
|
42
|
+
| `settingsNav` | Settings sub-shell entries | `<SettingsSidebar>` |
|
|
43
|
+
| Route registry | Custom pages mounted under `/$storeId/*` | Catch-all dispatcher route |
|
|
44
|
+
| Slot registry | Components injected into named slots in built-in pages | `<Slot name="...">` |
|
|
45
|
+
| Table registry | Columns (and their filters) on built-in list tables | `<ResourceTable>` |
|
|
46
|
+
|
|
47
|
+
Each is a module singleton with mutators to add, remove, and patch entries (nav can also insert relative to an existing entry or nest children under one). The consumer components subscribe to changes, so registering late still updates the UI on the next render.
|
|
48
|
+
|
|
49
|
+
The facade `defineDashboardPlugin({ nav, routes, slots, … })` groups all five into one declarative call — convenient for plugins, optional for in-app customizations (you can call `nav.add()` directly).
|
|
50
|
+
|
|
51
|
+
## Auth, context, providers
|
|
52
|
+
|
|
53
|
+
The signed-in admin's identity and abilities are exposed via three providers wrapping the app:
|
|
54
|
+
|
|
55
|
+
- `<AuthProvider>` — the current admin user; `useAuth()` returns `{ user, signIn, signOut, … }`
|
|
56
|
+
- `<PermissionProvider>` — CanCanCan abilities; `usePermissions()` returns `{ can, cannot }`
|
|
57
|
+
- `<StoreProvider>` — current store + timezone + currency; `useStore()` returns `{ storeId, store, … }`
|
|
58
|
+
|
|
59
|
+
When you write a custom page or hook, pull from these. **Never reach into local state for identity** — it changes on store switch or session refresh, and the providers wire all that for you.
|
|
60
|
+
|
|
61
|
+
## Data fetching
|
|
62
|
+
|
|
63
|
+
Every screen in the dashboard follows the same pattern:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
adminClient (SDK)
|
|
67
|
+
↓ called inside
|
|
68
|
+
useFoo (custom hook wrapping useQuery)
|
|
69
|
+
↓ consumed by
|
|
70
|
+
<FooList /> (component renders cached data, owns no fetching logic)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Wrap SDK calls in custom hooks under `src/hooks/`, never call `adminClient` directly from components. See [Backend integration](customization/backend.md) for the full pattern, error mapping, and the `useResourceMutation` helper that wires up 422 handling.
|
|
74
|
+
|
|
75
|
+
## URL = state
|
|
76
|
+
|
|
77
|
+
The dashboard treats the URL as the single source of truth for page state:
|
|
78
|
+
|
|
79
|
+
- Filters, sort, page number → URL search params
|
|
80
|
+
- Selected row → URL path param
|
|
81
|
+
- "Sheet open?" → URL search param (`?edit=prod_xxx`)
|
|
82
|
+
|
|
83
|
+
This is why links work, deep-links restore filter state, and the browser back button does the right thing. When you build a custom page, follow the same rule — don't stash filter state in React state where it's invisible to bookmarks and analytics.
|
|
84
|
+
|
|
85
|
+
## i18n is not optional
|
|
86
|
+
|
|
87
|
+
The dashboard ships with English, German, French, Polish, Arabic, and Simplified Chinese out of the box. Every label, placeholder, error message goes through `i18n.t()`. When you add a feature:
|
|
88
|
+
|
|
89
|
+
1. Add the keys to your customization's `locales/en.json`
|
|
90
|
+
2. Register the bundle: `i18n.addResourceBundle('en', 'translation', bundle, true, true)`
|
|
91
|
+
3. Use `useTranslation()` in components and `i18n.t()` at registration sites
|
|
92
|
+
|
|
93
|
+
[Translations](customization/translations.md) covers the field-key convention.
|
|
94
|
+
|
|
95
|
+
## Prefixed IDs everywhere
|
|
96
|
+
|
|
97
|
+
The Admin API uses [Stripe-style prefixed IDs](../../api-reference/admin-api/introduction.md): `prod_86Rf07xd4z`, `cust_k5nR8xLq`. The dashboard does too — URLs, hook params, table rows, SDK calls. Never coerce IDs to integers. Never strip the prefix. Pass them around as opaque strings.
|
|
98
|
+
|
|
99
|
+
## Where to go next
|
|
100
|
+
|
|
101
|
+
- [Public API](public-api.md) — what's importable from `@spree/dashboard-core` / `-ui` / `-dashboard`
|
|
102
|
+
- [Customization Quickstart](customization/quickstart.md) — first nav entry in 30 seconds
|
|
103
|
+
- [Recipes](recipes/custom-form-field.md) — focused walkthroughs for common extension shapes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Backend integration
|
|
3
|
+
sidebarTitle: Backend integration
|
|
4
|
+
description: Talk to your own (or Spree's) Admin API from the dashboard — typed SDK calls, custom endpoints, prefixed IDs, error handling, and React-Query hooks.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The dashboard never reaches into Rails models or fetches HTML. All data flows through `@spree/admin-sdk` — a typed Admin API client. This page covers the patterns: calling existing endpoints, adding new ones, handling errors, and wrapping it all in React-Query hooks for caching.
|
|
8
|
+
|
|
9
|
+
## The `adminClient`
|
|
10
|
+
|
|
11
|
+
The SPA boots a single `adminClient` instance and exposes it from `@spree/dashboard-core`. Resource methods follow `client.<resource>.<verb>()`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { adminClient } from '@spree/dashboard-core'
|
|
15
|
+
|
|
16
|
+
await adminClient.products.list({ filter: { name_cont: 'shirt' } })
|
|
17
|
+
await adminClient.products.get('prod_86Rf07xd4z', { expand: ['variants'] })
|
|
18
|
+
await adminClient.products.create({ name: 'New product' })
|
|
19
|
+
await adminClient.products.update('prod_86Rf07xd4z', { name: 'Renamed' })
|
|
20
|
+
await adminClient.products.delete('prod_86Rf07xd4z')
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
All IDs are [prefixed](../../../api-reference/admin-api/querying.md) — pass them in, get them out. The SDK never coerces to integers.
|
|
24
|
+
|
|
25
|
+
## Custom endpoints
|
|
26
|
+
|
|
27
|
+
When you've added an endpoint to `spree/api` that the SDK doesn't model yet (host-app routes, an in-development feature, a custom controller), use the public `request<T>()` escape hatch:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import type { AdminBrand } from './types'
|
|
31
|
+
|
|
32
|
+
const data = await adminClient.request<{ data: AdminBrand[] }>(
|
|
33
|
+
'GET',
|
|
34
|
+
'/brands',
|
|
35
|
+
{ params: { 'filter[name_cont]': 'nike' } },
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It accepts:
|
|
40
|
+
|
|
41
|
+
- `method` — `'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'`
|
|
42
|
+
- `path` — relative to `/api/v3/admin` (so `/brands` hits `/api/v3/admin/brands`)
|
|
43
|
+
- `body` — JSON-stringified for non-GET requests
|
|
44
|
+
- `params` — query string
|
|
45
|
+
|
|
46
|
+
Resource methods are just `request<T>()` calls with the right path + types pre-bound. When you publish your customization or generate types from your serializer, you can promote the inline call to a typed wrapper.
|
|
47
|
+
|
|
48
|
+
## React-Query hooks
|
|
49
|
+
|
|
50
|
+
The dashboard's data layer is React-Query — every `useFoo` hook is `useQuery({ queryKey, queryFn })` over an SDK call. Follow the same pattern for your own data:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { adminClient } from '@spree/dashboard-core'
|
|
54
|
+
import { useQuery } from '@tanstack/react-query'
|
|
55
|
+
import type { AdminBrand } from './types'
|
|
56
|
+
|
|
57
|
+
export function useBrands(params?: { search?: string }) {
|
|
58
|
+
return useQuery({
|
|
59
|
+
queryKey: ['brands', params],
|
|
60
|
+
queryFn: () =>
|
|
61
|
+
adminClient.request<{ data: AdminBrand[] }>('GET', '/brands', { params }),
|
|
62
|
+
staleTime: 30_000,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For mutations, prefer the existing `useResourceMutation` helper — it wires up the SDK's error shape, the 422 silencer, and the toast for non-validation errors:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { useResourceMutation } from '@spree/dashboard-core'
|
|
71
|
+
|
|
72
|
+
export function useUpdateBrand(id: string) {
|
|
73
|
+
return useResourceMutation({
|
|
74
|
+
mutationFn: (body: Partial<AdminBrand>) =>
|
|
75
|
+
adminClient.request<{ data: AdminBrand }>('PATCH', `/brands/${id}`, { body }),
|
|
76
|
+
invalidate: [['brands'], ['brand', id]],
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The `invalidate` array refetches dependent queries on success.
|
|
82
|
+
|
|
83
|
+
## Error handling
|
|
84
|
+
|
|
85
|
+
The SDK throws `SpreeError` for non-2xx responses. The error carries `status`, `code`, and `details` — the last contains the Rails error hash for 422s.
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { SpreeError } from '@spree/admin-sdk'
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await adminClient.products.update(id, payload)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err instanceof SpreeError && err.status === 422) {
|
|
94
|
+
console.log(err.details) // { name: ["can't be blank"] }
|
|
95
|
+
}
|
|
96
|
+
throw err
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
In a React Hook Form submit handler, prefer the higher-level helper:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { mapSpreeErrorsToForm } from '@spree/dashboard-core'
|
|
104
|
+
|
|
105
|
+
async function handleSubmit(values: FormValues) {
|
|
106
|
+
try {
|
|
107
|
+
await mutation.mutateAsync(values)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`mapSpreeErrorsToForm` routes flat field errors onto `aria-invalid` `<FieldError>` blocks and `:base` errors onto `errors.root.message` for a destructive banner. Returns `true` if it handled the error; otherwise re-throw so the global toast catches it.
|
|
115
|
+
|
|
116
|
+
## When to add a new endpoint
|
|
117
|
+
|
|
118
|
+
Pretty much always, for anything non-trivial. If you're filtering, sorting, or aggregating across multiple resources, do it on the backend — the API can join, scope, and serialize correctly in one round-trip, whereas the dashboard would have to fan out N requests and stitch them together. See the backend customization docs for adding controllers, serializers, and authorization.
|
|
119
|
+
|
|
120
|
+
## Reference
|
|
121
|
+
|
|
122
|
+
- [Admin API reference](../../../api-reference/admin-api/introduction.md) — auth, querying, errors, and every endpoint
|
|
123
|
+
- [`AdminClient.request`](https://github.com/spree/spree/blob/main/packages/admin-sdk/src/admin-client.ts) — full type
|
|
124
|
+
- [`SpreeError`](https://github.com/spree/spree/blob/main/packages/sdk-core/src/errors.ts)
|
|
125
|
+
- [`useResourceMutation`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-resource-mutation.ts)
|
|
126
|
+
- [`mapSpreeErrorsToForm`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/form-errors.ts)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Navigation
|
|
3
|
+
sidebarTitle: Navigation
|
|
4
|
+
description: Add, modify, and remove items from the dashboard's main sidebar and settings sub-nav.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The dashboard has two navigation registries: **main sidebar** (`nav`) and **settings sub-shell** (`settingsNav`). Both are module singletons exposed from `@spree/dashboard-core` and consumed by `<AppSidebar>` and `<SettingsSidebar>` via `useSyncExternalStore`, so late registration re-renders the sidebars automatically.
|
|
8
|
+
|
|
9
|
+
## Main sidebar (`nav`)
|
|
10
|
+
|
|
11
|
+
### Add an entry
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { nav } from '@spree/dashboard-core'
|
|
15
|
+
import { BarChartIcon } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
nav.add({
|
|
18
|
+
key: 'analytics',
|
|
19
|
+
label: 'Analytics',
|
|
20
|
+
path: '/analytics', // prefixed with /$storeId at render time
|
|
21
|
+
icon: BarChartIcon,
|
|
22
|
+
position: 650,
|
|
23
|
+
subject: 'Spree::Order', // optional CanCanCan subject — hides item without read permission
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`position` controls ordering — built-ins use 100–600 (Home 100, Orders 200, Products 300, Customers 400, Promotions 500, Reports 600; a conditional Getting Started entry sits at 50), leaving gaps to slot into. The default is 100. Keys must be unique, including against the built-in keys (`getting-started`, `home`, `orders`, `products`, `customers`, `promotions`, `reports`, `settings`) — duplicates throw at boot.
|
|
28
|
+
|
|
29
|
+
### Add via `defineDashboardPlugin`
|
|
30
|
+
|
|
31
|
+
The facade groups multiple registry calls:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
35
|
+
|
|
36
|
+
defineDashboardPlugin({
|
|
37
|
+
nav: [
|
|
38
|
+
{ key: 'analytics', label: 'Analytics', path: '/analytics', icon: BarChartIcon, position: 650 },
|
|
39
|
+
{ key: 'segments', label: 'Segments', path: '/segments', icon: UsersIcon, position: 660 },
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Nest children under a parent
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
nav.add({
|
|
48
|
+
key: 'analytics',
|
|
49
|
+
label: 'Analytics',
|
|
50
|
+
icon: BarChartIcon,
|
|
51
|
+
position: 650,
|
|
52
|
+
children: [
|
|
53
|
+
{ key: 'analytics.sales', label: 'Sales', path: '/analytics/sales' },
|
|
54
|
+
{ key: 'analytics.traffic', label: 'Traffic', path: '/analytics/traffic' },
|
|
55
|
+
],
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Children declare their own `subject` independently — the parent doesn't auto-gate them.
|
|
60
|
+
|
|
61
|
+
### Nest under an existing (built-in) parent
|
|
62
|
+
|
|
63
|
+
To add an item **inside** a menu you don't own — e.g. a "Brands" page under the built-in **Products** menu — use `addChild`, which preserves the parent's existing children. (`nav.update('products', { children: [...] })` would *replace* them, dropping the built-ins.)
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
nav.addChild('products', {
|
|
67
|
+
key: 'products.brands',
|
|
68
|
+
label: 'Brands',
|
|
69
|
+
path: '/products/brands',
|
|
70
|
+
subject: 'Spree::Brand',
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or declaratively, keyed by parent:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
defineDashboardPlugin({
|
|
78
|
+
nav: {
|
|
79
|
+
addChildren: {
|
|
80
|
+
products: [{ key: 'products.brands', label: 'Brands', path: '/products/brands' }],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Nesting under a missing parent throws; a duplicate child key throws (`updateChild` / `removeChild` mutate an existing one). Built-in parents (`products`, `orders`, `customers`, `promotions`) register during app bootstrap, before any plugin runs, so they're always available to nest into.
|
|
87
|
+
|
|
88
|
+
### Insert relative to an existing entry
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
nav.insertBefore('customers', { key: 'segments', label: 'Segments', path: '/segments' })
|
|
92
|
+
nav.insertAfter('orders', { key: 'returns', label: 'Returns', path: '/returns' })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The new entry inherits the target's `position` ± 1 unless you specify your own.
|
|
96
|
+
|
|
97
|
+
### Modify or remove
|
|
98
|
+
|
|
99
|
+
Imperatively:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
nav.update('orders', { label: 'All orders' })
|
|
103
|
+
nav.remove('legacy-thing')
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or declaratively in `defineDashboardPlugin` — the `nav` object form adds, removes, and patches in one config (built-in entries included; they register before any plugin runs):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
defineDashboardPlugin({
|
|
110
|
+
nav: {
|
|
111
|
+
add: [{ key: 'reviews', label: 'Reviews', path: '/reviews', position: 650 }],
|
|
112
|
+
remove: ['promotions'],
|
|
113
|
+
update: { products: { label: 'Catalog', position: 150 } },
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The array form (`nav: [...]`) remains the shorthand for `{ add: [...] }`. The object form also takes `addChildren` (see [Nest under an existing parent](#nest-under-an-existing-built-in-parent)). `remove` of an unknown key is a no-op; `update` of an unknown key throws. `settingsNav` accepts the same object form (minus `addChildren` — settings entries are flat).
|
|
119
|
+
|
|
120
|
+
### Bottom-pinned entries
|
|
121
|
+
|
|
122
|
+
`section: 'bottom'` pins to the sidebar footer (where "Settings" lives by default):
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
nav.add({
|
|
126
|
+
key: 'help',
|
|
127
|
+
label: 'Help',
|
|
128
|
+
path: '/help',
|
|
129
|
+
icon: HelpCircleIcon,
|
|
130
|
+
section: 'bottom',
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Conditional visibility and badges
|
|
135
|
+
|
|
136
|
+
Beyond permission gating with `subject`, a top-level entry can hide itself based on app state (`if`) and render a component after its label (`badge`):
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
nav.add({
|
|
140
|
+
key: 'onboarding',
|
|
141
|
+
label: 'Onboarding',
|
|
142
|
+
path: '/onboarding',
|
|
143
|
+
// Receives the current store/user/permissions; return false to hide.
|
|
144
|
+
if: ({ store }) => !storeFullyConfigured(store),
|
|
145
|
+
// A component, not an element — it can call hooks and return null.
|
|
146
|
+
badge: PendingTasksBadge,
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The built-in Getting Started entry works exactly this way: it shows a remaining-tasks count and disappears once every setup task is done. `if` combines with `subject` — both must pass.
|
|
151
|
+
|
|
152
|
+
## Settings sub-nav (`settingsNav`)
|
|
153
|
+
|
|
154
|
+
The settings page has its own sub-shell sidebar with grouped entries (Store, Localization, Team & Access, …). Entries cluster under groups defined separately.
|
|
155
|
+
|
|
156
|
+
### Register a group
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { settingsNav } from '@spree/dashboard-core'
|
|
160
|
+
|
|
161
|
+
settingsNav.addGroup({
|
|
162
|
+
key: 'integrations',
|
|
163
|
+
label: 'Integrations',
|
|
164
|
+
position: 500,
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Add entries to a group
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
settingsNav.add({
|
|
172
|
+
key: 'stripe-tax',
|
|
173
|
+
label: 'Stripe Tax',
|
|
174
|
+
path: '/integrations/stripe-tax', // prefixed with /$storeId/settings
|
|
175
|
+
group: 'integrations',
|
|
176
|
+
position: 100,
|
|
177
|
+
subject: 'Spree::TaxRate',
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `comingSoon` badge
|
|
182
|
+
|
|
183
|
+
Disabled entries with a "Soon" badge — useful for staging the rollout:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
settingsNav.add({
|
|
187
|
+
key: 'reports',
|
|
188
|
+
label: 'Reports',
|
|
189
|
+
path: '/reports',
|
|
190
|
+
group: 'analytics',
|
|
191
|
+
comingSoon: true,
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Permission gating
|
|
196
|
+
|
|
197
|
+
`subject` on a nav entry checks `permissions.can('read', subject)` — when it fails, the sidebar item is hidden. **This is UX, not authorization.** The backend still enforces CanCanCan via `authorize!` on every API call. Hiding the link is a hint, not a security boundary.
|
|
198
|
+
|
|
199
|
+
## Order of operations
|
|
200
|
+
|
|
201
|
+
Nav registrations run at module-load time. The sidebar reads via `useSyncExternalStore`, so:
|
|
202
|
+
|
|
203
|
+
- Registering before the sidebar mounts: works (the snapshot picks it up on first render)
|
|
204
|
+
- Registering after the sidebar mounts: also works (the store notifies subscribers)
|
|
205
|
+
|
|
206
|
+
The only ordering rule is **i18n**: if you use `i18n.t('admin.foo.label')` for `label`, your translation bundle must be registered first. The framework's own translations load before `src/plugins.ts` runs, so the rule reduces to: put your `i18n.addResourceBundle(...)` call at the top of `plugins.ts`, above the registrations that use it. See [Translations](translations.md).
|
|
207
|
+
|
|
208
|
+
## Reference
|
|
209
|
+
|
|
210
|
+
- [`NavEntry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/nav-registry.ts) — the full shape including types for `children`, `position`, `subject`, `section`
|
|
211
|
+
- [`SettingsNavEntry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/settings-nav-registry.ts) — settings sub-nav types
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Permissions
|
|
3
|
+
sidebarTitle: Permissions
|
|
4
|
+
description: How to hide UI behind CanCanCan permissions in your customisations — and why hiding is never the same as authorising.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The dashboard exposes the current admin's CanCanCan abilities to every component via the `usePermissions()` hook. The registry surfaces — nav entries, settings entries, custom routes — also accept a `subject` shortcut, and nav entries take a generic `if` predicate that reads from `permissions`.
|
|
8
|
+
|
|
9
|
+
## Two layers
|
|
10
|
+
|
|
11
|
+
UI gating is for **UX**. Backend authorization is for **security**. They are not the same:
|
|
12
|
+
|
|
13
|
+
| Layer | Where | What it does |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| UI gating | Dashboard registries (`subject`, `if`) | Hides menu items, columns, buttons — keeps the interface tidy |
|
|
16
|
+
| Authorization | Rails API controllers (CanCanCan, scopes) | Refuses the request when the user doesn't have permission |
|
|
17
|
+
|
|
18
|
+
**Always rely on the backend for security.** Hiding a button is a hint to the user, not a wall. A user with browser dev tools (or the API token) can always hit the endpoint directly — your `authorize!` call is what stops them.
|
|
19
|
+
|
|
20
|
+
## The `permissions` object
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
interface Permissions {
|
|
24
|
+
can(action: ActionName, subject: SubjectName): boolean
|
|
25
|
+
cannot(action: ActionName, subject: SubjectName): boolean
|
|
26
|
+
/** True when the matching rule has per-record conditions — expect possible 403s. */
|
|
27
|
+
isConditional(action: ActionName, subject: SubjectName): boolean
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It mirrors the backend ability at the **class level**:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
permissions.can('read', 'Spree::Order')
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Subjects are strings (`'Spree::Order'`, `'Spree::Product'`, or your own `'MyApp::Report'`). There is no client-side record-level check — when a rule is conditional on record attributes, `isConditional` returns `true` and the API is the arbiter: render the control and handle a possible 403.
|
|
38
|
+
|
|
39
|
+
## `subject` shortcut
|
|
40
|
+
|
|
41
|
+
Available on `NavEntry`, `SettingsNavEntry`, and `RouteEntry`. It hides the entry unless the user can `read` the subject:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
nav.add({
|
|
45
|
+
key: 'reports',
|
|
46
|
+
label: 'Reports',
|
|
47
|
+
path: '/reports',
|
|
48
|
+
subject: 'Spree::Order', // hides nav item without read:Spree::Order
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For routes, `subject` also renders a 403 page if the user navigates directly:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
routes: [{
|
|
56
|
+
key: 'reports',
|
|
57
|
+
path: '/reports',
|
|
58
|
+
component: ReportsPage,
|
|
59
|
+
subject: 'Spree::Order',
|
|
60
|
+
}],
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `subject` whenever you want a "user can read this resource" check. For anything else, use `if`.
|
|
64
|
+
|
|
65
|
+
## `if` predicate (nav entries)
|
|
66
|
+
|
|
67
|
+
`if` on a nav entry is the escape hatch. It runs at render with `{ permissions, store, user }` and returns a boolean:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import type { Store } from '@spree/admin-sdk'
|
|
71
|
+
|
|
72
|
+
nav.add({
|
|
73
|
+
key: 'reports',
|
|
74
|
+
label: 'Reports',
|
|
75
|
+
path: '/reports',
|
|
76
|
+
if: ({ permissions, store }) =>
|
|
77
|
+
permissions.can('read', 'Spree::Order') &&
|
|
78
|
+
!!(store as Store | null)?.setup_tasks?.every((task) => task.done),
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
(The `if` context types `store` loosely — cast to the SDK's `Store` for typed access, the same way the built-in Getting Started entry does.)
|
|
83
|
+
|
|
84
|
+
Use it for combined checks (permission + store state), feature flags, multi-action checks (`can(update) && can(read)`), or anything the `subject` shortcut can't express in one string. It combines with `subject` — both must pass. (Slot entries have an `if` too, but it receives only the slot's own context — see [Slots](slots.md).)
|
|
85
|
+
|
|
86
|
+
## Inside components
|
|
87
|
+
|
|
88
|
+
Use the `usePermissions()` hook — it returns `{ permissions, rules, isLoading }`:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { usePermissions } from '@spree/dashboard-core'
|
|
92
|
+
|
|
93
|
+
function ReportsPage() {
|
|
94
|
+
const { permissions } = usePermissions()
|
|
95
|
+
if (!permissions.can('read', 'Spree::Order')) {
|
|
96
|
+
return <Forbidden />
|
|
97
|
+
}
|
|
98
|
+
return <ReportsList />
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The same `permissions` object backs the nav registry's `if` predicate, so you can move logic between the two without changing behaviour. For declarative gating, `<Can I="update" a="Spree::Order">…</Can>` (also from `@spree/dashboard-core`) renders children only when the check passes.
|
|
103
|
+
|
|
104
|
+
## Custom abilities
|
|
105
|
+
|
|
106
|
+
If your customization introduces a new model on the backend, extend the CanCanCan ability there (typically a decorator on `Spree::PermissionSets::DefaultCustomer` or `Spree::PermissionSets::DashboardManagement`):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# config/initializers/spree.rb (host app)
|
|
110
|
+
Spree::PermissionSets::DashboardManagement.class_eval do
|
|
111
|
+
def activate!
|
|
112
|
+
super
|
|
113
|
+
can :manage, MyApp::Report
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
After that, `permissions.can('read', 'MyApp::Report')` resolves in the dashboard exactly like a first-party check — the abilities ship to the dashboard with the current-user response (`GET /api/v3/admin/me`) at sign-in.
|
|
119
|
+
|
|
120
|
+
## Reference
|
|
121
|
+
|
|
122
|
+
- [`Permissions` interface](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/providers/permission-provider.tsx)
|
|
123
|
+
- [CanCanCan docs](https://github.com/CanCanCommunity/cancancan) — the Ruby ability definition language
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Quickstart
|
|
3
|
+
sidebarTitle: Quickstart
|
|
4
|
+
description: Add your first customization to the Spree dashboard in under 5 minutes — no npm publishing, no build configuration, just one file in your dashboard app.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This guide assumes two things are running:
|
|
8
|
+
|
|
9
|
+
1. A Spree API server (your Spree store's backend).
|
|
10
|
+
2. Your dashboard app — the small Vite project that renders the admin. In a `create-spree-app` project it lives at `apps/dashboard/`; add it to an existing project with `npx spree add dashboard`, or scaffold it anywhere with `npx spree add dashboard --template` pointing at your own copy. Start it with `pnpm dev`.
|
|
11
|
+
|
|
12
|
+
All customizations in this guide are edits to your dashboard app's own code. **You don't need to scaffold a plugin** — plugins are for [distributing features to other stores](../plugins/overview.md).
|
|
13
|
+
|
|
14
|
+
## The 30-second example
|
|
15
|
+
|
|
16
|
+
Add an "Analytics" item to the dashboard sidebar.
|
|
17
|
+
|
|
18
|
+
**Step 1.** Open `src/plugins.ts` — the starter ships this file already wired up — and register a nav entry:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
22
|
+
import { BarChartIcon } from 'lucide-react'
|
|
23
|
+
|
|
24
|
+
defineDashboardPlugin({
|
|
25
|
+
nav: [{
|
|
26
|
+
key: 'analytics',
|
|
27
|
+
label: 'Analytics',
|
|
28
|
+
path: '/analytics',
|
|
29
|
+
icon: BarChartIcon,
|
|
30
|
+
// Built-in entries use positions 100–600 (Home … Reports).
|
|
31
|
+
// 650 places Analytics after Reports, at the end.
|
|
32
|
+
position: 650,
|
|
33
|
+
}],
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Step 2.** Save. The dev server hot-reloads and the sidebar now shows "Analytics".
|
|
38
|
+
|
|
39
|
+
Clicking it lands on a "Page not found" screen — we registered the nav entry but no page. Let's fix that.
|
|
40
|
+
|
|
41
|
+
## Add the page
|
|
42
|
+
|
|
43
|
+
**Step 3.** Create the page component at `src/pages/analytics.tsx`:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
import { PageHeader } from '@spree/dashboard-core'
|
|
47
|
+
import { ResourceLayout } from '@spree/dashboard-ui'
|
|
48
|
+
|
|
49
|
+
export function AnalyticsPage() {
|
|
50
|
+
return (
|
|
51
|
+
<ResourceLayout
|
|
52
|
+
header={<PageHeader title="Analytics" subtitle="Store performance over time" />}
|
|
53
|
+
main={<p>Coming soon.</p>}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Step 4.** Register the route in `src/plugins.ts`:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
63
|
+
import { BarChartIcon } from 'lucide-react'
|
|
64
|
+
import { AnalyticsPage } from './pages/analytics'
|
|
65
|
+
|
|
66
|
+
defineDashboardPlugin({
|
|
67
|
+
nav: [{
|
|
68
|
+
key: 'analytics',
|
|
69
|
+
label: 'Analytics',
|
|
70
|
+
path: '/analytics',
|
|
71
|
+
icon: BarChartIcon,
|
|
72
|
+
position: 650,
|
|
73
|
+
}],
|
|
74
|
+
routes: [{
|
|
75
|
+
key: 'analytics',
|
|
76
|
+
path: '/analytics',
|
|
77
|
+
component: AnalyticsPage,
|
|
78
|
+
}],
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Save. Click "Analytics" — your page renders inside the dashboard chrome, at `/<store id>/analytics`.
|
|
83
|
+
|
|
84
|
+
> **WARNING:** Every `key` must be unique, including against the built-ins (`getting-started`, `home`, `orders`, `products`, `customers`, `promotions`, `reports`, `settings`). Registering a duplicate key throws at boot with a message naming the conflict.
|
|
85
|
+
|
|
86
|
+
## What just happened
|
|
87
|
+
|
|
88
|
+
`defineDashboardPlugin` registers extensions against the dashboard's shared registries: `nav`, `routes`, `slots`, `tables`, `settingsNav`, `formFields`, and `customFieldComponents`. Your `src/plugins.ts` runs when the app boots (the starter's `main.tsx` imports it), so everything is registered before the first render. These are the exact same APIs distributable plugins use — the only difference is packaging.
|
|
89
|
+
|
|
90
|
+
The custom route mounts under `/<store id>/analytics`. Routes registered this way are matched at navigation time, so they can even be registered lazily after boot.
|
|
91
|
+
|
|
92
|
+
Building a **distributable plugin** rather than customizing your own app? Ship pages as [file routes](routes.md) instead — they compile into the app's route tree, so links to them are type-checked. The registry form shown here is for in-app customization.
|
|
93
|
+
|
|
94
|
+
## Next steps
|
|
95
|
+
|
|
96
|
+
- [Navigation](navigation.md) — sidebar entries, nesting under built-in menus, settings sub-nav, permission gating
|
|
97
|
+
- [Routes](routes.md) — path patterns, params, permission fallback
|
|
98
|
+
- [Slots](slots.md) — inject widgets into built-in pages without forking them
|
|
99
|
+
- [Tables](tables.md) — define your own list page or extend built-in tables
|
|
100
|
+
- [Translations](translations.md) — add i18n keys without colliding with the framework
|