@spree/docs 0.1.122 → 0.1.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/api-reference/admin-api/endpoints.md +1 -1
  2. package/dist/developer/cli/admin-api.md +11 -0
  3. package/dist/developer/dashboard/concepts.md +103 -0
  4. package/dist/developer/dashboard/customization/backend.md +126 -0
  5. package/dist/developer/dashboard/customization/navigation.md +211 -0
  6. package/dist/developer/dashboard/customization/permissions.md +123 -0
  7. package/dist/developer/dashboard/customization/quickstart.md +100 -0
  8. package/dist/developer/dashboard/customization/routes.md +187 -0
  9. package/dist/developer/dashboard/customization/slots.md +114 -0
  10. package/dist/developer/dashboard/customization/tables.md +167 -0
  11. package/dist/developer/dashboard/customization/translations.md +96 -0
  12. package/dist/developer/dashboard/deployment.md +80 -0
  13. package/dist/developer/dashboard/overview.md +105 -0
  14. package/dist/developer/dashboard/plugins/distributing.md +137 -0
  15. package/dist/developer/dashboard/plugins/overview.md +46 -0
  16. package/dist/developer/dashboard/plugins/publishing.md +142 -0
  17. package/dist/developer/dashboard/plugins/scaffolding.md +133 -0
  18. package/dist/developer/dashboard/public-api.md +241 -0
  19. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +212 -0
  20. package/dist/developer/dashboard/recipes/custom-form-field.md +174 -0
  21. package/dist/developer/dashboard/recipes/page-action-button.md +189 -0
  22. package/dist/developer/dashboard/recipes/sidebar-widget.md +147 -0
  23. package/dist/developer/dashboard/slots-catalog.md +173 -0
  24. package/dist/developer/sdk/admin/extending.md +70 -0
  25. package/dist/developer/sdk/admin/quickstart.md +3 -3
  26. package/dist/developer/tutorial/admin.md +2 -0
  27. package/dist/developer/tutorial/api.md +2 -0
  28. package/dist/developer/tutorial/extending-models.md +2 -0
  29. package/dist/developer/tutorial/model.md +2 -0
  30. package/package.json +1 -1
@@ -0,0 +1,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.