@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,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
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routes
|
|
3
|
+
sidebarTitle: Routes
|
|
4
|
+
description: Mount custom pages under your dashboard's `/$storeId/...` path. Supports path params, permission gating, and works alongside the dashboard's built-in routes.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The dashboard's first-party routes (`customers/`, `orders/`, `products/`, …) are file-based via TanStack Router and live in `@spree/dashboard/src/routes/`. Custom pages have two mechanisms:
|
|
8
|
+
|
|
9
|
+
1. **File routes** (packaged plugins — preferred): the plugin ships ordinary TanStack route files, and the host build compiles them into its typed route tree. Typed `<Link>`s with no casts, route-level code splitting, `validateSearch`, loaders — full router citizenship.
|
|
10
|
+
2. **Route registry** (in-app & dynamic): `defineDashboardPlugin({ routes })` entries matched at navigation time by a catch-all dispatcher at `/_authenticated/$storeId/$`. Use for host-app customizations (`plugins.ts`) and anything registered conditionally at runtime. File routes always win over the registry — the catch-all is the lowest-priority match.
|
|
11
|
+
|
|
12
|
+
## File routes (packaged plugins)
|
|
13
|
+
|
|
14
|
+
Declare a routes directory in your plugin's marker and put TanStack route files in it:
|
|
15
|
+
|
|
16
|
+
```jsonc
|
|
17
|
+
// package.json
|
|
18
|
+
"spree": { "dashboard": { "plugin": true, "routes": "./src/routes" } }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
src/
|
|
23
|
+
├── pages/ # page components
|
|
24
|
+
└── routes/
|
|
25
|
+
├── brands.index.tsx # → /$storeId/brands
|
|
26
|
+
└── brands.$brandId.tsx # → /$storeId/brands/br_123
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// src/routes/brands.index.tsx
|
|
31
|
+
import { resourceSearchSchema } from '@spree/dashboard-core'
|
|
32
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
33
|
+
import { BrandsListPage } from '../pages/brands-list'
|
|
34
|
+
|
|
35
|
+
export const Route = createFileRoute('/_authenticated/$storeId/brands/')({
|
|
36
|
+
validateSearch: resourceSearchSchema,
|
|
37
|
+
component: () => <BrandsListPage searchParams={Route.useSearch()} />,
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rules that make this work:
|
|
42
|
+
|
|
43
|
+
- **Commit the final composed path literal.** Plugin routes mount under the dashboard's `_authenticated/$storeId` layout; the route generator verifies the literal (and would rewrite a wrong one).
|
|
44
|
+
- **The host regenerates its `routeTree.gen.ts` on every dev start and build** from installed package versions — updating your plugin picks up new routes automatically (dev servers need a restart, same as installing a plugin).
|
|
45
|
+
- **Exclude `src/routes` from your package's standalone `tsc`** — `createFileRoute` paths only type-check against a generated tree, which exists in host programs. Develop against a dashboard host (the scaffold's tsconfig ships this exclusion).
|
|
46
|
+
|
|
47
|
+
Links to file routes are fully typed — no casts:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
<Link to="/$storeId/brands/$brandId" params={{ brandId: brand.id }}>view</Link>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Route collisions
|
|
54
|
+
|
|
55
|
+
Two packages can't own the same route path. If a plugin declares a path that another plugin — or a built-in dashboard page — already claims, the build fails before generating the tree with an error naming both **packages**, the path, and each file:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Dashboard route collision.
|
|
59
|
+
Route "/_authenticated/$storeId/brands/" is declared by more than one package:
|
|
60
|
+
- @acme/brands (…/node_modules/@acme/brands/src/routes/brands.index.tsx)
|
|
61
|
+
- @other/brands (…/node_modules/@other/brands/src/routes/brands.index.tsx)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Rename one of the routes, or drop the plugin that shouldn't own it. (Duplicate paths *within* a single package are reported by the TanStack generator instead — that's an authoring bug in one package.)
|
|
65
|
+
|
|
66
|
+
> **WARNING:** The collision check only sees **file routes**. If a runtime-registry route (below) uses the same URL as a compiled file route, there's no build error — the file route silently wins on every navigation, because the registry's catch-all is the lowest-priority match. If a page you registered at runtime never renders, check whether an installed plugin claims the same path.
|
|
67
|
+
|
|
68
|
+
## Runtime route registry (in-app & dynamic)
|
|
69
|
+
|
|
70
|
+
Register from your host app's `plugins.ts` (or a plugin that genuinely needs runtime registration):
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
74
|
+
import { ReportsPage } from './pages/reports'
|
|
75
|
+
|
|
76
|
+
defineDashboardPlugin({
|
|
77
|
+
routes: [{
|
|
78
|
+
key: 'reports',
|
|
79
|
+
path: '/reports',
|
|
80
|
+
component: ReportsPage,
|
|
81
|
+
}],
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`path` is **relative** to `/$storeId` — the dispatcher prepends the prefix at match time. So `/reports` matches the URL `/store_xyz/reports`. The leading `/` is required.
|
|
86
|
+
|
|
87
|
+
## Path parameters
|
|
88
|
+
|
|
89
|
+
TanStack-Router-style `$name` tokens match a single non-empty segment:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
routes: [
|
|
93
|
+
{ key: 'report-detail', path: '/reports/$reportId', component: ReportDetailPage },
|
|
94
|
+
{ key: 'report-export', path: '/reports/$reportId/export', component: ReportExportPage },
|
|
95
|
+
],
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Receiving params
|
|
99
|
+
|
|
100
|
+
Your component receives three props from the dispatcher:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
interface PluginRouteProps {
|
|
104
|
+
/** Path params extracted from the URL. Keys match the `$name` tokens in your path. */
|
|
105
|
+
params: Record<string, string>
|
|
106
|
+
/** Always set — every route is scoped to a store. */
|
|
107
|
+
storeId: string
|
|
108
|
+
/** URL search-state. Pass to `<ResourceTable searchParams={searchParams} />` so filters
|
|
109
|
+
* round-trip through the URL. */
|
|
110
|
+
searchParams: Record<string, unknown>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ReportDetailPage({ params, storeId, searchParams }: PluginRouteProps) {
|
|
114
|
+
const reportId = params.reportId
|
|
115
|
+
// ...
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`searchParams` is typed as `Record<string, unknown>` because the dispatcher can't statically know your page's shape. If you're handing it to `<ResourceTable>`, cast to `ResourceSearch`:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import type { ResourceSearch } from '@spree/dashboard-core'
|
|
123
|
+
|
|
124
|
+
<ResourceTable searchParams={searchParams as ResourceSearch} ... />
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Permission gating
|
|
128
|
+
|
|
129
|
+
A `subject` on the route entry renders a 403 fallback (instead of the page) when the user lacks `read` permission:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
routes: [{
|
|
133
|
+
key: 'reports',
|
|
134
|
+
path: '/reports',
|
|
135
|
+
component: ReportsPage,
|
|
136
|
+
subject: 'Spree::Order',
|
|
137
|
+
}],
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Always pair this with a matching `subject` on the **nav entry** so users without permission never see the link in the first place. The route subject is the defensive layer for direct URLs.
|
|
141
|
+
|
|
142
|
+
`subject` is a **registry-route** feature — the dispatcher applies it before rendering. File routes don't pass through the dispatcher, so they gate themselves: check permissions inside the component with `<Can>` or `usePermissions()` from `@spree/dashboard-core` and render a fallback. Either way, this is UX only — the backend still authorizes every API call.
|
|
143
|
+
|
|
144
|
+
## Layout
|
|
145
|
+
|
|
146
|
+
Custom routes inherit the dashboard's `_authenticated` + `$storeId` layout (auth guard, sidebar, top bar, etc.) — your component renders inside the `<Outlet />`. Use `<ResourceLayout>` from `@spree/dashboard-ui` to get the same header/main/sidebar grid as core pages:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import { PageHeader } from '@spree/dashboard-core'
|
|
150
|
+
import { ResourceLayout } from '@spree/dashboard-ui'
|
|
151
|
+
|
|
152
|
+
function ReportsPage() {
|
|
153
|
+
return (
|
|
154
|
+
<ResourceLayout
|
|
155
|
+
header={<PageHeader title="Reports" backTo="dashboard" />}
|
|
156
|
+
main={<ReportsList />}
|
|
157
|
+
sidebar={<ReportsFilters />}
|
|
158
|
+
/>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Linking to a registry route
|
|
164
|
+
|
|
165
|
+
Registry routes aren't in the generated route tree, so links to them bypass static checking:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { Link } from '@tanstack/react-router'
|
|
169
|
+
|
|
170
|
+
<Link
|
|
171
|
+
to={'/$storeId/reports/$reportId' as string}
|
|
172
|
+
params={{ storeId, reportId } as never}
|
|
173
|
+
>
|
|
174
|
+
View report
|
|
175
|
+
</Link>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The casts bypass TanStack Router's static type-checking (custom routes aren't in the generated route tree, so neither the path nor its params exist as types). Params are passed as a keyed object — each key fills the matching `$name` token.
|
|
179
|
+
|
|
180
|
+
## Order of operations
|
|
181
|
+
|
|
182
|
+
Routes register at module-load time. The dispatcher reads from the registry on **every navigation**, so route registration is much more forgiving than nav: even a route registered after first render works as soon as the user navigates to it. The only ordering rule is the usual i18n one — register your translation bundle above any registration that calls `i18n.t(...)` (see [Translations](translations.md)).
|
|
183
|
+
|
|
184
|
+
## Reference
|
|
185
|
+
|
|
186
|
+
- [`RouteEntry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/route-registry.ts) — full type
|
|
187
|
+
- [Catch-all dispatcher source](https://github.com/spree/spree/blob/main/packages/dashboard/src/routes/_authenticated/$storeId/$.tsx) — see how matching works
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Slots
|
|
3
|
+
sidebarTitle: Slots
|
|
4
|
+
description: Inject your own widgets into existing dashboard pages without forking them. Slots are named injection points the dashboard exposes on built-in pages.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A slot is a named injection point in a built-in page. Components registered against a slot render inside it, sorted by `position`, gated by an optional `if` predicate. The host page knows nothing about your widget — it just renders `<Slot name="..." context={...} />` and the slot registry handles the rest.
|
|
8
|
+
|
|
9
|
+
## Quick example
|
|
10
|
+
|
|
11
|
+
Render a card with the current user's email in every page's actions dropdown:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { defineDashboardPlugin } from '@spree/dashboard-core'
|
|
15
|
+
import { DropdownMenuItem } from '@spree/dashboard-ui'
|
|
16
|
+
|
|
17
|
+
function UserBadge({ user }: { user?: { email: string } }) {
|
|
18
|
+
if (!user) return null
|
|
19
|
+
return <DropdownMenuItem>{user.email}</DropdownMenuItem>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
defineDashboardPlugin({
|
|
23
|
+
slots: {
|
|
24
|
+
'page.actions_dropdown': [{
|
|
25
|
+
id: 'my-user-badge',
|
|
26
|
+
component: UserBadge as never,
|
|
27
|
+
position: 50,
|
|
28
|
+
}],
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`page.actions_dropdown` is one of the slots exposed by `<PageHeader>`. See the [Slots catalog](../slots-catalog.md) for the full list and the context shape each slot provides.
|
|
34
|
+
|
|
35
|
+
## Slot context
|
|
36
|
+
|
|
37
|
+
The component receives the **slot-specific context** as props — whatever the host page passes via `<Slot name="..." context={{ product, /* etc */ }} />`. The shape varies per slot: `page.actions_dropdown` passes `{ resource }`, `product.form_sidebar` passes `{ product }`, and so on. Each slot's shape is documented in the [Slots catalog](../slots-catalog.md).
|
|
38
|
+
|
|
39
|
+
For anything beyond the slot's own context — permissions, the current store, the signed-in user — call the hooks inside your component: `usePermissions()`, `useStore()`, `useAuth()` from `@spree/dashboard-core`. (A future release may inject these as ambient props, but today the hooks are the way.)
|
|
40
|
+
|
|
41
|
+
### Why `as never`?
|
|
42
|
+
|
|
43
|
+
`defineDashboardPlugin`'s `slots` field is typed as a generic-erased `Record<string, SlotEntry[]>` — the facade can't statically know your slot's context shape, only the host page does. Cast your component to `as never` to satisfy the registry's signature. Your component still receives the right props at runtime; the cast only suppresses the type-system noise.
|
|
44
|
+
|
|
45
|
+
If you'd rather have full type safety, call `registerSlot` directly with the explicit type parameter:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { registerSlot } from '@spree/dashboard-core'
|
|
49
|
+
|
|
50
|
+
interface ProductSlotContext {
|
|
51
|
+
product: { id: string; brand_id?: string | null }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
registerSlot<ProductSlotContext>('product.form_sidebar', {
|
|
55
|
+
id: 'brand-card',
|
|
56
|
+
component: ({ product }) => <BrandCard productId={product.id} brandId={product.brand_id} />,
|
|
57
|
+
position: 50,
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Permission gating
|
|
62
|
+
|
|
63
|
+
Gate inside the component with `usePermissions()` — return `null` when the user lacks access:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { usePermissions } from '@spree/dashboard-core'
|
|
67
|
+
|
|
68
|
+
function AdminOnlyMenuItem() {
|
|
69
|
+
const { permissions } = usePermissions()
|
|
70
|
+
if (!permissions.can('manage', 'Spree::User')) return null
|
|
71
|
+
return <DropdownMenuItem>…</DropdownMenuItem>
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The entry-level `if` predicate also exists, but it only receives the slot's own context (the same props your component gets) — use it to skip an entry based on the host page's data, e.g. `if: ({ product }) => !!product.brand_id`. When it returns `false`, the entry is skipped entirely (no DOM nodes mounted).
|
|
76
|
+
|
|
77
|
+
## Binding to the host form
|
|
78
|
+
|
|
79
|
+
Slots on form pages (product, category, store settings — see the [slots catalog](../slots-catalog.md#detail-page-form-slots) for which) render **inside the page's `<form>`** and expose its react-hook-form instance. A widget can register inputs against it with `useHostForm()` — they hydrate from the API, flip the Save button on change, and persist in the page's own save, with no save logic in the widget:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { useHostForm } from '@spree/dashboard-core'
|
|
83
|
+
|
|
84
|
+
function TechSpecsCard() {
|
|
85
|
+
const form = useHostForm<{ tech_specs: string }>()
|
|
86
|
+
return <Textarea {...form.register('tech_specs')} />
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Pair it with a `formFields` registration so the field hydrates and ships in the payload — the full walkthrough is the [custom form field recipe](../recipes/custom-form-field.md). Never render your own `<form>` inside a slot on these pages: HTML forbids nested forms, and the host form already owns submission. On pages without a host form (orders, customers), `useHostForm()` throws — use `useOptionalHostForm()` and fall back to your own state + API save.
|
|
91
|
+
|
|
92
|
+
## Position
|
|
93
|
+
|
|
94
|
+
`position` controls render order within the slot. Built-in entries (when the dashboard ships any) use 100/200/300 to leave gaps. Default is 100, so two entries with no explicit position render in registration order.
|
|
95
|
+
|
|
96
|
+
## Removing or updating an entry
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { removeSlot, updateSlot } from '@spree/dashboard-core'
|
|
100
|
+
|
|
101
|
+
removeSlot('product.form_sidebar', 'brand-card')
|
|
102
|
+
updateSlot('product.form_sidebar', 'brand-card', { position: 999 })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
These mirror the registry's mutator pattern — useful for development-mode toggles, or for unregistering a third-party slot entry from your host.
|
|
106
|
+
|
|
107
|
+
## Order of operations
|
|
108
|
+
|
|
109
|
+
Like nav, slot entries register at module-load time and the `<Slot>` consumer re-renders via `useSyncExternalStore` when entries change. Late registration works; the slot just lights up the next render.
|
|
110
|
+
|
|
111
|
+
## Reference
|
|
112
|
+
|
|
113
|
+
- [Slots catalog](../slots-catalog.md) — every named slot the dashboard exposes
|
|
114
|
+
- [`SlotEntry`, `SlotAmbientContext`, `registerSlot`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/slot-registry.ts) — full registry source
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tables
|
|
3
|
+
sidebarTitle: Tables
|
|
4
|
+
description: Add, change, or remove columns on built-in list tables (products, orders, customers, …), or define a table for your own resource.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Every list page in the dashboard renders through `<ResourceTable>`, which reads its column definitions from a **table registry** keyed by table name (`products`, `orders`, `customers`, …). That gives you two levers:
|
|
8
|
+
|
|
9
|
+
1. **Extend a built-in table** — add, patch, or remove columns (including their filters) without forking the page.
|
|
10
|
+
2. **Define your own table** — declare columns once with `defineTable`, then render them with `<ResourceTable>` on your own page.
|
|
11
|
+
|
|
12
|
+
## Add a column to a built-in table
|
|
13
|
+
|
|
14
|
+
Imperatively, from `src/plugins.ts` (or a plugin entry):
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { tables } from '@spree/dashboard-core'
|
|
18
|
+
|
|
19
|
+
tables.products.addColumn({
|
|
20
|
+
key: 'brand',
|
|
21
|
+
label: 'Brand',
|
|
22
|
+
default: true, // visible out of the box
|
|
23
|
+
render: (product) => product.brand?.name ?? '—',
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or declaratively through `defineDashboardPlugin` — same effect, grouped with your other extensions:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
defineDashboardPlugin({
|
|
31
|
+
tables: {
|
|
32
|
+
products: {
|
|
33
|
+
add: [{ key: 'brand', label: 'Brand', default: true, render: (p) => p.brand?.name ?? '—' }],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
New columns append after the existing ones. Column keys must be unique per table — adding a duplicate throws with a message telling you to use `updateColumn` instead.
|
|
40
|
+
|
|
41
|
+
> **NOTE:** **Timing is taken care of.** Built-in tables register when their page first loads, which may be after your code runs. Mutations against a table that isn't registered yet are queued and replayed the moment it appears — and mutations against a table that never registers simply never fire. That means extending an optional feature's table is safe even when that feature isn't installed.
|
|
42
|
+
|
|
43
|
+
## Modify or remove built-in columns
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
tables.products.updateColumn('price', { label: 'Retail price' })
|
|
47
|
+
tables.products.removeColumn('cost_price')
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Declaratively:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
defineDashboardPlugin({
|
|
54
|
+
tables: {
|
|
55
|
+
products: {
|
|
56
|
+
update: { price: { label: 'Retail price' } },
|
|
57
|
+
remove: ['cost_price'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`updateColumn` patches shallowly — pass only the fields you want to change. `removeColumn` of an unknown key is a no-op.
|
|
64
|
+
|
|
65
|
+
## Column definition
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
interface ColumnDef<T> {
|
|
69
|
+
key: string // unique per table; also the default Ransack sort/filter attribute
|
|
70
|
+
label: string // header text — use i18n.t(...) for translated labels
|
|
71
|
+
render?: (row: T) => ReactNode // cell renderer; defaults to rendering row[key] as text
|
|
72
|
+
className?: string // cell class (e.g. 'text-right tabular-nums')
|
|
73
|
+
default?: boolean // part of the visible column set out of the box
|
|
74
|
+
sortable?: boolean // header toggles Ransack sort on the attribute
|
|
75
|
+
filterable?: boolean // appears in the filter panel
|
|
76
|
+
ransackAttribute?: string // backend attribute when it differs from key
|
|
77
|
+
displayable?: boolean // set false for filter-only entries (no column rendered)
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Filter types
|
|
82
|
+
|
|
83
|
+
When a column is `filterable`, `filterType` decides which filter UI it gets:
|
|
84
|
+
|
|
85
|
+
| `filterType` | Filter UI | Extra fields |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `'string'` (default) | Text input with contains/equals operators | — |
|
|
88
|
+
| `'boolean'` | Yes/no toggle | — |
|
|
89
|
+
| `'number'`, `'currency'` | Numeric comparisons | — |
|
|
90
|
+
| `'date'` | Date range picker (store-timezone aware) | — |
|
|
91
|
+
| `'enum'` | Fixed option list | `filterOptions: [{ value, label }]` (required) |
|
|
92
|
+
| `'resource'` | Multi-select autocomplete against another resource | `filterResource` (required) — `queryKey`, `search`, `hydrate`, `getOptionLabel` |
|
|
93
|
+
| `'tags'` | Tag autocomplete | `taggableType` (required), e.g. `'Spree::Product'` |
|
|
94
|
+
|
|
95
|
+
A `'resource'` filter searches as the admin types and resolves already-selected IDs back to labels on page reload:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
tables.products.addColumn({
|
|
99
|
+
key: 'brand_id',
|
|
100
|
+
label: 'Brand',
|
|
101
|
+
displayable: false, // filter only — no column in the table
|
|
102
|
+
filterable: true,
|
|
103
|
+
filterType: 'resource',
|
|
104
|
+
filterResource: {
|
|
105
|
+
queryKey: 'brands',
|
|
106
|
+
search: (query) => brandsClient.list({ q: { name_cont: query } }),
|
|
107
|
+
hydrate: (ids) => brandsClient.list({ q: { id_in: ids } }),
|
|
108
|
+
getOptionLabel: (brand) => brand.name,
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Filtering and sorting are executed by the Admin API (Ransack), not in the browser — `key` (or `ransackAttribute`) must be a filterable/sortable attribute the API allows.
|
|
114
|
+
|
|
115
|
+
## Define your own table
|
|
116
|
+
|
|
117
|
+
For your own list page, declare the table once — typically right next to your `defineDashboardPlugin` call:
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { defineTable } from '@spree/dashboard-core'
|
|
121
|
+
|
|
122
|
+
defineTable<Brand>('brands', {
|
|
123
|
+
title: 'Brands',
|
|
124
|
+
searchParam: 'search',
|
|
125
|
+
searchPlaceholder: 'Search by name…',
|
|
126
|
+
defaultSort: { field: 'name', direction: 'asc' },
|
|
127
|
+
emptyMessage: 'No brands yet',
|
|
128
|
+
columns: [
|
|
129
|
+
{ key: 'name', label: 'Name', sortable: true, filterable: true, default: true },
|
|
130
|
+
{ key: 'products_count', label: 'Products', default: true, className: 'text-right tabular-nums' },
|
|
131
|
+
{ key: 'created_at', label: 'Created', sortable: true, default: false },
|
|
132
|
+
],
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then render it from your page component:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
<ResourceTable<Brand>
|
|
140
|
+
tableKey="brands" // which table definition to read
|
|
141
|
+
queryKey="brands" // TanStack Query cache key
|
|
142
|
+
queryFn={(params) => brandsClient.list(params)}
|
|
143
|
+
searchParams={searchParams}
|
|
144
|
+
/>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`<ResourceTable>` handles pagination, sorting, the filter panel, column visibility, and URL round-tripping of all of it. See [Routes](routes.md) for the page around it — the [example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) shows the complete wiring.
|
|
148
|
+
|
|
149
|
+
## Bulk actions, row actions, reordering
|
|
150
|
+
|
|
151
|
+
These are **props on `<ResourceTable>`**, composed by the page that renders the table:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
<ResourceTable<Brand>
|
|
155
|
+
// …
|
|
156
|
+
bulkActions={[{ key: 'archive', label: 'Archive selected', onSelect: async (rows) => { /* … */ } }]}
|
|
157
|
+
rowActions={(row) => <BrandRowMenu brand={row} />}
|
|
158
|
+
/>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Use them freely on your own pages. They are **not registry-driven** — a plugin can't inject bulk or row actions into a built-in page's table today. If a built-in page needs an injection point, that's what [slots](slots.md) are for.
|
|
162
|
+
|
|
163
|
+
## Reference
|
|
164
|
+
|
|
165
|
+
- [`table-registry.ts`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/table-registry.ts) — `defineTable`, `tables`, the full `ColumnDef` union
|
|
166
|
+
- [`<ResourceTable>`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/components/resource-table.tsx) — props for bulk actions, row actions, drag-reordering
|
|
167
|
+
- [Example Brands plugin](https://github.com/spree/spree/tree/main/packages/dashboard-plugin-example) — a real `defineTable` + `<ResourceTable>` + products-column extension
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Translations
|
|
3
|
+
sidebarTitle: Translations
|
|
4
|
+
description: Add your own i18n strings to the dashboard so labels, validation messages, and notifications respect the admin's chosen locale.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The dashboard uses [i18next](https://www.i18next.com/) with the `react-i18next` bindings. All strings live in i18next's default `translation` namespace, with every key under a top-level `admin.` prefix (`admin.nav.orders`, `admin.fields.<resource>.<attribute>.label`, …). You register your strings into the same namespace with the same prefix so they look and feel native.
|
|
8
|
+
|
|
9
|
+
## Add translations
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { i18n } from '@spree/dashboard-core'
|
|
13
|
+
|
|
14
|
+
i18n.addResourceBundle('en', 'translation', {
|
|
15
|
+
admin: {
|
|
16
|
+
reports: {
|
|
17
|
+
title: 'Reports',
|
|
18
|
+
empty: 'No reports yet.',
|
|
19
|
+
},
|
|
20
|
+
fields: {
|
|
21
|
+
report: {
|
|
22
|
+
name: { label: 'Report name', placeholder: 'e.g. Q4 sales by region' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}, /* deep */ true, /* overwrite */ true)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With `deep: true` your keys merge into the existing tree instead of replacing it; `overwrite: true` lets your bundle win where a key genuinely collides (this is what the framework and the official plugins use). Note the `admin` wrapper is part of the **bundle contents** — the namespace argument stays `'translation'`.
|
|
30
|
+
|
|
31
|
+
Repeat per locale:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
for (const [locale, bundle] of Object.entries({ en, fr, de })) {
|
|
38
|
+
i18n.addResourceBundle(locale, 'translation', bundle, true, true)
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Use them
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { useTranslation } from 'react-i18next'
|
|
46
|
+
|
|
47
|
+
function ReportsHeader() {
|
|
48
|
+
const { t } = useTranslation()
|
|
49
|
+
return <h1>{t('admin.reports.title')}</h1>
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In a registry entry, pass the key directly:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
nav.add({
|
|
57
|
+
key: 'reports',
|
|
58
|
+
label: i18n.t('admin.reports.title'),
|
|
59
|
+
path: '/reports',
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Note: `i18n.t` resolves at the call site, so the label is a snapshot taken at registration time. Registry entries store plain strings — if the user switches language without a reload, registered labels keep their old language until re-registered. Components that call `useTranslation()` re-render on locale change; registry labels don't. If that matters for your entry, re-register it on the i18n `languageChanged` event.
|
|
64
|
+
|
|
65
|
+
## Field-key convention
|
|
66
|
+
|
|
67
|
+
Form labels, placeholders, and help text follow a two-level key convention:
|
|
68
|
+
|
|
69
|
+
1. `admin.fields.<resource>.<attribute>.<facet>` — resource-specific, e.g. `admin.fields.report.name.label`
|
|
70
|
+
2. `admin.fields.<attribute>.<facet>` — cross-resource defaults, e.g. `admin.fields.name.label`
|
|
71
|
+
|
|
72
|
+
Components call `t()` with these keys explicitly — pick the resource-scoped form for labels that differ per resource, and reuse the shared `admin.fields.<attribute>.<facet>` keys the framework already ships for common attributes (`name`, `email`, `created_at`, `storefront_visible`, …).
|
|
73
|
+
|
|
74
|
+
In dev mode, missing keys log to the console (in production a missing key falls back to a humanized version of the attribute name).
|
|
75
|
+
|
|
76
|
+
## Server-side error messages
|
|
77
|
+
|
|
78
|
+
The `mapSpreeErrorsToForm` helper routes 422 responses onto `form.formState.errors`, but the messages themselves are the **server's strings, verbatim** — `{ "name": ["can't be blank"] }` becomes a field error reading "can't be blank". To localize validation messages, configure the locale on the backend (Rails i18n translates ActiveRecord error messages); the dashboard displays whatever the API returns.
|
|
79
|
+
|
|
80
|
+
## Order of operations
|
|
81
|
+
|
|
82
|
+
Register translations **before** any code that calls `i18n.t()` at module-load time (registry labels, table titles, etc.). The framework's own bundles are loaded before your code runs, so the pattern is simply — top of the file:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
// src/plugins.ts (same pattern in a plugin package's entry module)
|
|
86
|
+
import { defineDashboardPlugin, i18n } from '@spree/dashboard-core'
|
|
87
|
+
|
|
88
|
+
i18n.addResourceBundle('en', 'translation', en, true, true) // ① translations first
|
|
89
|
+
|
|
90
|
+
defineDashboardPlugin({ /* ② registrations may now use i18n.t(...) */ })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Reference
|
|
94
|
+
|
|
95
|
+
- [i18next docs](https://www.i18next.com/) — full API
|
|
96
|
+
- The dashboard's own bundle: [`packages/dashboard/src/locales/en.json`](https://github.com/spree/spree/blob/main/packages/dashboard/src/locales/en.json) — copy keys for forms, status badges, validation messages, etc.
|