@spree/docs 0.1.236 → 0.1.238
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/authentication.md +45 -0
- package/dist/api-reference/store.yaml +130 -130
- package/dist/api-reference/webhooks-events.md +1 -1
- package/dist/developer/contributing/developing-spree.md +2 -2
- package/dist/developer/core-concepts/metafields.md +1 -1
- package/dist/developer/core-concepts/store-credits-gift-cards.md +3 -5
- package/dist/developer/core-concepts/stores.md +1 -1
- package/dist/developer/customization/configuration.md +8 -8
- package/dist/developer/customization/dependencies.md +9 -9
- package/dist/developer/customization/metadata.md +3 -3
- package/dist/developer/customization/quickstart.md +7 -7
- package/dist/developer/customization/validations.md +1 -1
- package/dist/developer/dashboard/customization/backend.md +7 -4
- package/dist/developer/dashboard/customization/navigation.md +5 -3
- package/dist/developer/dashboard/customization/permissions.md +3 -1
- package/dist/developer/dashboard/customization/quickstart.md +3 -4
- package/dist/developer/dashboard/customization/routes.md +7 -6
- package/dist/developer/dashboard/customization/slots.md +7 -6
- package/dist/developer/dashboard/customization/tables.md +4 -2
- package/dist/developer/dashboard/customization/translations.md +4 -2
- package/dist/developer/dashboard/overview.md +5 -4
- package/dist/developer/dashboard/public-api.md +16 -2
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +2 -3
- package/dist/developer/dashboard/recipes/custom-form-field.md +12 -6
- package/dist/developer/dashboard/recipes/page-action-button.md +9 -8
- package/dist/developer/dashboard/recipes/sidebar-widget.md +11 -5
- package/dist/developer/dashboard/slots-catalog.md +1 -1
- package/dist/developer/how-to/build-a-marketplace.md +3 -3
- package/dist/developer/how-to/custom-digital-asset-provider.md +1 -1
- package/dist/developer/how-to/custom-document-numbers.md +3 -3
- package/dist/developer/how-to/custom-promotion.md +1 -1
- package/dist/developer/how-to/sell-digital-products.md +1 -1
- package/dist/developer/providers/dam.md +3 -1
- package/dist/developer/providers/erp.md +3 -1
- package/dist/developer/providers/pim.md +3 -1
- package/dist/developer/providers/sso.md +1 -1
- package/dist/developer/tutorial/dashboard.md +307 -0
- package/dist/developer/tutorial/events.md +66 -6
- package/dist/developer/tutorial/introduction.md +42 -13
- package/dist/developer/tutorial/model-and-api.md +187 -0
- package/dist/developer/tutorial/storefront.md +123 -6
- package/dist/developer/tutorial/testing.md +163 -7
- package/dist/developer/upgrades/5.6-to-6.0.md +16 -0
- package/package.json +1 -1
- package/dist/developer/tutorial/admin-api.md +0 -13
- package/dist/developer/tutorial/dashboard-plugin.md +0 -14
- package/dist/developer/tutorial/model.md +0 -13
- package/dist/developer/tutorial/store-api.md +0 -13
|
@@ -1,24 +1,53 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Tutorial: Build a Custom Feature"
|
|
3
|
-
description: Build
|
|
3
|
+
description: Build a complete Brands feature in Spree — model and API from one generator, a dashboard plugin, storefront rendering, events, and tests.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
This tutorial builds one complete feature — **
|
|
6
|
+
This tutorial builds one complete feature — **Brands**, a manufacturer or label attached to products — through every layer of a Spree application. By the end you will have a custom model with its own Store and Admin API, brand management in the dashboard, brands rendered on the storefront, other systems reacting to brand changes, and tests covering all of it.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
It is written for **both backend and frontend developers**. The backend code you write is minimal and mostly generated — Spree's generators produce convention-correct models, controllers and serializers, and the TypeScript SDK gives you typed access from your storefront. Most of what you write by hand is TypeScript.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
## What you'll build
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
| Finishing | [Events](events.md) → [Testing](testing.md) | Always |
|
|
12
|
+
- A `Spree::Brand` model with a Store API and an Admin API — one generator command
|
|
13
|
+
- A brands screen in the dashboard: a list page, navigation and a card on the product page
|
|
14
|
+
- Brands on the storefront, fetched through `@spree/sdk` with generated types
|
|
15
|
+
- Lifecycle events other systems subscribe to, in the app or over webhooks
|
|
16
|
+
- Specs for the model and both APIs, plus an end-to-end pass
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
## The five steps
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
- [1. Models & API](model-and-api.md) — Generate the model, migration, Store and Admin controllers, serializers and routes in one command
|
|
22
|
+
- [2. Dashboard](dashboard.md) — Scaffold the back-office UI with the CLI — a table, a form, navigation and a product slot
|
|
23
|
+
- [3. Storefront](storefront.md) — Regenerate SDK types and render brands on the product page in the Next.js storefront
|
|
24
|
+
- [4. Events](events.md) — Publish lifecycle events and consume them with subscribers and outbound webhooks
|
|
25
|
+
- [5. Testing](testing.md) — Model and request specs, dashboard plugin tests, and one end-to-end check
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
The order is deliberate. Step 1 gives every later step something real to talk to: the dashboard plugin calls the Admin API, the storefront calls the Store API, and the events in step 4 reuse the serializer the same generator produced.
|
|
20
29
|
|
|
21
30
|
## Before you start
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
You need a running Spree application. The fastest path is [create-spree-app](../create-spree-app/quickstart.md), a Docker-based project with the [Spree CLI](../cli/quickstart.md) and nothing to install locally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx create-spree-app@latest my-store
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Every command in this tutorial runs from the root of that project**, through the `spree` CLI. Nothing needs installing on your machine — the CLI routes each command into the right container.
|
|
39
|
+
|
|
40
|
+
The project it creates has this shape, and the paths in every chapter are relative to its root:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
my-store/
|
|
44
|
+
├── server/ # the Server app — models, API, specs
|
|
45
|
+
├── apps/
|
|
46
|
+
│ ├── dashboard/ # the admin SPA (React)
|
|
47
|
+
│ └── storefront/ # the Next.js storefront
|
|
48
|
+
└── docker-compose.yml
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **NOTE:** **create-spree-app projects:** run `spree eject` once before starting. Fresh projects use a prebuilt Docker image; ejecting switches to the development setup that mounts your app into the container, so the files the generators create land in your project and reload as you edit them.
|
|
52
|
+
|
|
53
|
+
> **TIP:** Using an AI coding agent? Install the [Spree agent skills](../agentic/agent-skills.md) (`npx skills add spree/agent-skills`) and connect the [docs MCP server](../agentic/mcp.md) before starting. Your agent will know the conventions this tutorial teaches and can follow along with you. See [AI-assisted development](../agentic/overview.md).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Models & API
|
|
3
|
+
sidebarTitle: Models & API
|
|
4
|
+
description: Generate the Brand model, migration, Store and Admin API controllers, serializers and routes with a single spree:api_resource command.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Every feature starts with a model and the API that exposes it. They arrive together: `spree:api_resource` generates the model, its migration, a customer-facing Store API, a back-office Admin API, serializers, routes, a factory and controller specs in one command.
|
|
8
|
+
|
|
9
|
+
This matters for the rest of the tutorial. The dashboard plugin in step 2 talks to the Admin API, the storefront in step 3 talks to the Store API, and the events in step 4 reuse the serializer generated here. Getting this step right makes the other four mostly configuration.
|
|
10
|
+
|
|
11
|
+
## Step 1: Generate the resource
|
|
12
|
+
|
|
13
|
+
A brand has a name, a slug for friendly URLs, and an active flag:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
spree generate api_resource Brand name:string:index slug:string:uniq active:boolean
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The generator reports what it created:
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
create app/models/spree/brand.rb
|
|
23
|
+
create db/migrate/XXXXXXXXXXXXXX_create_spree_brands.rb
|
|
24
|
+
create app/controllers/spree/api/v3/store/brands_controller.rb
|
|
25
|
+
create app/controllers/spree/api/v3/admin/brands_controller.rb
|
|
26
|
+
create app/serializers/spree/api/v3/brand_serializer.rb
|
|
27
|
+
create app/serializers/spree/api/v3/admin/brand_serializer.rb
|
|
28
|
+
create spec/factories/spree/brand_factory.rb
|
|
29
|
+
create spec/controllers/spree/api/v3/store/brands_controller_spec.rb
|
|
30
|
+
create spec/controllers/spree/api/v3/admin/brands_controller_spec.rb
|
|
31
|
+
|
|
32
|
+
✓ Generated Spree::Brand API resource
|
|
33
|
+
|
|
34
|
+
Prefixed ID: brand_xxxxxxxxxx
|
|
35
|
+
Store API: /api/v3/store/brands (read-only)
|
|
36
|
+
Admin API: /api/v3/admin/brands (full CRUD)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Apply the migration:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
spree migrate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> **INFO:** Every command in this tutorial runs from the root of your [create-spree-app](../create-spree-app/quickstart.md) project. `spree` routes each one into the right container, so generators write into `server/` and nothing needs installing on your machine.
|
|
46
|
+
|
|
47
|
+
### The field syntax
|
|
48
|
+
|
|
49
|
+
Attributes follow the familiar `name:type:index` form, with the Spree conventions applied for you:
|
|
50
|
+
|
|
51
|
+
| You write | You get |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `name:string:index` | A non-null string column with an index |
|
|
54
|
+
| `slug:string:uniq` | A string column with a unique index |
|
|
55
|
+
| `active:boolean` | A boolean column with a default |
|
|
56
|
+
| `user:belongs_to` | A reference with the class name resolved |
|
|
57
|
+
|
|
58
|
+
Useful flags: `--paranoid` for soft delete, `--custom-fields` for [custom fields](../core-concepts/metafields.md) support, `--writable` to give the Store API create, update and destroy as well, and `--no-store` or `--no-admin` to generate only one side. Run the generator with `--help` for the full list.
|
|
59
|
+
|
|
60
|
+
## Step 2: What you got
|
|
61
|
+
|
|
62
|
+
You do not need to read the generated code to use it. The command produced a complete, convention-correct API, and the contract it gives you is this:
|
|
63
|
+
|
|
64
|
+
| Surface | Endpoint | Who calls it | Default access |
|
|
65
|
+
|---|---|---|---|
|
|
66
|
+
| Store API | `/api/v3/store/brands` | Storefronts, mobile apps | Read-only |
|
|
67
|
+
| Admin API | `/api/v3/admin/brands` | Dashboard, integrations, scripts | Full CRUD |
|
|
68
|
+
|
|
69
|
+
Three conventions are worth knowing, because every later step depends on them:
|
|
70
|
+
|
|
71
|
+
- **Public IDs, never database IDs.** Records are addressed as `brand_k5nR8xLq`. The API accepts and returns that form everywhere.
|
|
72
|
+
- **Two serializers, one inheriting the other.** The Store serializer carries public fields only; the Admin serializer extends it and adds `created_at` and `updated_at`. Add a public field once and both surfaces get it. This is why customers never see back-office data by accident.
|
|
73
|
+
- **Filtering is allowlisted.** `name`, `slug` and `active` are queryable because the generator listed them. Anything not on that list is rejected, so a caller cannot filter by a column you did not intend to expose.
|
|
74
|
+
|
|
75
|
+
The Store API is read-only by default — a shopper cannot create a brand. Pass `--writable` if a resource genuinely needs customer writes, as carts and addresses do.
|
|
76
|
+
|
|
77
|
+
**Brands belong to a store.** Every resource you generate is scoped to one, because commerce data — catalog, configuration, orders — is always per-store. A new record picks up its store automatically, and a field marked unique is unique *within* a store, so two stores can each have a brand called `wilson`. Only genuinely global reference data, like countries, opts out with `--no-store-scoped`.
|
|
78
|
+
|
|
79
|
+
> **WARNING:** One decision is still yours: **authorization**. Give the resource a permission rule before exposing it, and read lookups through the store (`current_store.brands`) rather than the whole table. Scoping by association is the cheapest defence against one store reading another's records — filtering by role is not a substitute.
|
|
80
|
+
|
|
81
|
+
## Step 3: Try it
|
|
82
|
+
|
|
83
|
+
Create a brand through the Admin API. You need a secret key from **Settings → API keys** in the dashboard:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
curl -X POST "http://localhost:3000/api/v3/admin/brands" \
|
|
87
|
+
-H "X-Spree-API-Key: sk_YOUR_KEY" \
|
|
88
|
+
-H "Content-Type: application/json" \
|
|
89
|
+
-d '{ "name": "Wilson", "slug": "wilson", "active": true }'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"id": "brand_k5nR8xLq",
|
|
95
|
+
"name": "Wilson",
|
|
96
|
+
"slug": "wilson",
|
|
97
|
+
"active": true,
|
|
98
|
+
"created_at": "2026-01-15T10:30:00Z",
|
|
99
|
+
"updated_at": "2026-01-15T10:30:00Z"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Note the flat request body. API v3 takes no `{ "brand": { ... } }` wrapper around the attributes, and the response identifies the record as `brand_k5nR8xLq` rather than by a database ID.
|
|
104
|
+
|
|
105
|
+
Now read it back from the Store API, using a publishable key from the same settings page:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
|
|
109
|
+
"http://localhost:3000/api/v3/store/brands"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"data": [
|
|
115
|
+
{ "id": "brand_k5nR8xLq", "name": "Wilson", "slug": "wilson", "active": true }
|
|
116
|
+
],
|
|
117
|
+
"meta": { "count": 1, "page": 1, "pages": 1 }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The same record, minus the timestamps. That difference is the serializer split doing its job: customers get public fields, staff get the operational ones.
|
|
122
|
+
|
|
123
|
+
Filtering works through the allowlist:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
|
|
127
|
+
"http://localhost:3000/api/v3/store/brands?q[name_cont]=wil"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
> **TIP:** Prefer TypeScript? The same call through the SDK is `client.request('GET', '/brands')` — step 3 covers the typed client in full.
|
|
131
|
+
|
|
132
|
+
## Connect brands to products
|
|
133
|
+
|
|
134
|
+
A brand on its own is a list. Adding a `brand_id` to products is what makes it useful in steps 2 and 3:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
spree generate migration AddBrandToSpreeProducts brand:belongs_to
|
|
138
|
+
spree migrate
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then connect the two models. Extending a core model like `Spree::Product` is the one place this step needs backend code — a [decorator](../customization/decorators.md) adds behavior to a core class without forking it. The generator writes the file:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
spree generate model_decorator Product
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Add one line inside the generated `prepended` block:
|
|
148
|
+
|
|
149
|
+
```ruby server/app/models/spree/product_decorator.rb
|
|
150
|
+
base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
And the matching line in `app/models/spree/brand.rb`, which is your own file:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
has_many :products, class_name: 'Spree::Product', dependent: :nullify
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`optional: true` lets a product exist without a brand. `dependent: :nullify` means deleting a brand clears the reference rather than deleting the products that carried it — almost always what you want for a label attached to a catalog.
|
|
160
|
+
|
|
161
|
+
Two more lines make the association usable from the API. The product serializer has to expose it, or the storefront cannot read a product's brand:
|
|
162
|
+
|
|
163
|
+
```ruby server/app/serializers/spree/api/v3/product_serializer_decorator.rb
|
|
164
|
+
attribute(:brand_id) { |product| product.brand&.prefixed_id }
|
|
165
|
+
has_one :brand, serializer: Spree::Api::V3::BrandSerializer
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The model has to allow filtering by it, or `?q[brand_id_eq]=…` is rejected:
|
|
169
|
+
|
|
170
|
+
```ruby server/app/models/spree/product_decorator.rb
|
|
171
|
+
base.whitelisted_ransackable_attributes |= %w[brand_id]
|
|
172
|
+
base.whitelisted_ransackable_associations |= %w[brand]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The allowlist is deliberate: every entry is a query any caller can run, so add only what the storefront needs.
|
|
176
|
+
|
|
177
|
+
Finally, the Admin API has to accept `brand_id` on write, or the picker you build in step 2 will appear to work and save nothing. Extensions append to the model's list rather than replacing it:
|
|
178
|
+
|
|
179
|
+
```ruby server/config/initializers/spree.rb
|
|
180
|
+
Spree::Product.additional_permitted_attributes += [:brand_id]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Always `+=`, never `=` — assigning would drop whatever another extension added.
|
|
184
|
+
|
|
185
|
+
## Next step
|
|
186
|
+
|
|
187
|
+
The data and both APIs are live. Now give staff a way to manage brands: [Dashboard](dashboard.md).
|
|
@@ -1,12 +1,129 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Storefront
|
|
3
|
-
description: Render
|
|
3
|
+
description: Render brands on the Next.js storefront in apps/storefront — call the Store API through @spree/sdk and build the product and brand pages.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
The Store API endpoint becomes typed TypeScript and a component on the product page. This
|
|
6
|
+
The Store API endpoint from step 1 becomes typed TypeScript and a component on the product page. This step closes the loop from the Server app to the customer’s screen.
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Step 1: Describe the resource to TypeScript
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
The storefront lives at `apps/storefront/` and talks to the Store API through `@spree/sdk`. Built-in resources like products and carts already ship typed. A resource you added is new to the SDK, so declare its shape once and reuse it:
|
|
11
|
+
|
|
12
|
+
```typescript apps/storefront/src/types/brand.ts
|
|
13
|
+
export interface Brand {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
slug: string | null
|
|
17
|
+
active: boolean
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
These are exactly the fields your Store serializer returns — the public ones, without the timestamps the Admin API adds. Keep this file in step with the serializer: it is the contract between the two halves of your project.
|
|
22
|
+
|
|
23
|
+
> **NOTE:** Spree generates the built-in SDK types from its own serializers, which is why `client.products` is typed without you doing anything. That pipeline runs inside the Spree repository, not your app, so a resource you add gets a hand-written interface like the one above.
|
|
24
|
+
|
|
25
|
+
## Step 2: Call the endpoint
|
|
26
|
+
|
|
27
|
+
`createClient` gives you a typed Store API client. Built-in resources have methods; custom ones go through `request`, which carries the same authentication, retries and locale defaults:
|
|
28
|
+
|
|
29
|
+
```typescript apps/storefront/src/lib/spree.ts
|
|
30
|
+
import { createClient } from '@spree/sdk'
|
|
31
|
+
|
|
32
|
+
export const client = createClient({
|
|
33
|
+
baseUrl: process.env.NEXT_PUBLIC_SPREE_API_URL!,
|
|
34
|
+
publishableKey: process.env.NEXT_PUBLIC_SPREE_PUBLISHABLE_KEY!,
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Fetching brands then goes through `request`, with paths relative to `/api/v3/store`:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import type { PaginatedResponse } from '@spree/sdk'
|
|
42
|
+
import { client } from '@/lib/spree'
|
|
43
|
+
import type { Brand } from '@/types/brand'
|
|
44
|
+
|
|
45
|
+
const brands = await client.request<PaginatedResponse<Brand>>('GET', '/brands')
|
|
46
|
+
const wilson = await client.request<Brand>('GET', '/brands/brand_k5nR8xLq')
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Only the fields the Store serializer declared come back. Timestamps and anything admin-only are absent by design — that is the serializer split from step 1 doing its job.
|
|
50
|
+
|
|
51
|
+
> **NOTE:** A generated resource is addressed by its prefixed ID. Looking one up by slug needs a `find_resource` override on the controller — the products endpoint does exactly that, so `/products/{slug}` works. Until you add one for brands, route by ID and treat `slug` as display data.
|
|
52
|
+
|
|
53
|
+
## Step 3: Expand brand on products
|
|
54
|
+
|
|
55
|
+
Because `Spree::Product` now belongs to a brand, product responses can carry it. Ask for it with `expand`:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// Without expand — the id only
|
|
59
|
+
const product = await client.products.get('prod_86Rf07xd4z')
|
|
60
|
+
|
|
61
|
+
// With expand — the nested brand object
|
|
62
|
+
const withBrand = await client.products.get('prod_86Rf07xd4z', {
|
|
63
|
+
expand: ['brand'],
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Expanding avoids a second round trip per product, which matters on a product listing page.
|
|
68
|
+
|
|
69
|
+
> **INFO:** For `expand: ['brand']` to work, the Product serializer must declare the association and the model must allowlist it for filtering. Add `brand` to the product's `whitelisted_ransackable_associations` if you also want `?q[brand_name_cont]=wilson`. Every allowlist entry is a query any caller can run, so add only what the storefront needs.
|
|
70
|
+
|
|
71
|
+
## Step 4: Render it on the product page
|
|
72
|
+
|
|
73
|
+
In the Next.js storefront, fetch the product with its brand and render the name:
|
|
74
|
+
|
|
75
|
+
```tsx apps/storefront/src/app/products/[slug]/page.tsx
|
|
76
|
+
import { client } from '@/lib/spree'
|
|
77
|
+
|
|
78
|
+
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
79
|
+
const { slug } = await params
|
|
80
|
+
const product = await client.products.get(slug, { expand: ['brand'] })
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<article>
|
|
84
|
+
{product.brand && (
|
|
85
|
+
<a href={`/brands/${product.brand.id}`} className="text-sm text-neutral-500">
|
|
86
|
+
{product.brand.name}
|
|
87
|
+
</a>
|
|
88
|
+
)}
|
|
89
|
+
<h1>{product.name}</h1>
|
|
90
|
+
<p>{product.price.display}</p>
|
|
91
|
+
</article>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
And a brand page listing everything that brand makes:
|
|
97
|
+
|
|
98
|
+
```tsx apps/storefront/src/app/brands/[id]/page.tsx
|
|
99
|
+
import { client } from '@/lib/spree'
|
|
100
|
+
import type { Brand } from '@/types/brand'
|
|
101
|
+
|
|
102
|
+
export default async function BrandPage({ params }: { params: Promise<{ id: string }> }) {
|
|
103
|
+
const { id } = await params
|
|
104
|
+
const brand = await client.request<Brand>('GET', `/brands/${id}`)
|
|
105
|
+
const products = await client.products.list({ brand_id_eq: brand.id })
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<section>
|
|
109
|
+
<h1>{brand.name}</h1>
|
|
110
|
+
<p>{products.meta.count} products</p>
|
|
111
|
+
<ul>
|
|
112
|
+
{products.data.map((product) => (
|
|
113
|
+
<li key={product.id}>
|
|
114
|
+
<a href={`/products/${product.slug}`}>{product.name}</a>
|
|
115
|
+
</li>
|
|
116
|
+
))}
|
|
117
|
+
</ul>
|
|
118
|
+
</section>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`brand_id_eq` is a Ransack predicate, available because `brand_id` is on the product's ransackable allowlist. See [search and filtering](../core-concepts/search-filtering.md) for the full predicate list.
|
|
124
|
+
|
|
125
|
+
> **NOTE:** The storefront runs on its own dev server. From the project root, `spree dev` starts the API and dashboard; start the storefront alongside them with `cd apps/storefront && pnpm dev`. It reads the API URL and publishable key from `apps/storefront/.env.local`.
|
|
126
|
+
|
|
127
|
+
## Next step
|
|
128
|
+
|
|
129
|
+
Customers can see brands. Now let other systems react when they change: [Events](events.md).
|
|
@@ -1,13 +1,169 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Testing
|
|
3
|
-
description: Test the feature at every layer — model and
|
|
3
|
+
description: Test the Brands feature at every layer — model and API specs with RSpec, dashboard tests with Vitest, and one end-to-end Playwright pass.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Each layer
|
|
6
|
+
Each layer gets the test that suits it: fast backend specs for the model and both APIs, component tests for the dashboard, and one end-to-end pass proving the whole path works.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Your project arrives with testing already configured — RSpec, FactoryBot, and Spree's own test helpers, including every factory core ships. You can write a spec against `create(:product)` without setting anything up.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
## Step 1: Run what the generator wrote
|
|
11
|
+
|
|
12
|
+
The generator in step 1 already wrote a factory and a controller spec per API side, so this step is mostly filling in rather than starting over:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
server/spec/factories/spree/brand_factory.rb
|
|
16
|
+
server/spec/controllers/spree/api/v3/store/brands_controller_spec.rb
|
|
17
|
+
server/spec/controllers/spree/api/v3/admin/brands_controller_spec.rb
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Run them:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
spree rspec spec/controllers/spree/api/v3/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The generated factory is the starting point for every other spec — always build records through factories rather than creating them directly, so a change to the model does not mean editing fifty specs.
|
|
27
|
+
|
|
28
|
+
## Step 2: Model specs
|
|
29
|
+
|
|
30
|
+
Test what you wrote, not what the framework already guarantees. A presence validation on a required column needs no spec; the association behavior you chose does:
|
|
31
|
+
|
|
32
|
+
```ruby server/spec/models/spree/brand_spec.rb
|
|
33
|
+
RSpec.describe Spree::Brand, type: :model do
|
|
34
|
+
it 'nullifies the reference instead of deleting products' do
|
|
35
|
+
brand = create(:brand)
|
|
36
|
+
product = create(:product, brand: brand)
|
|
37
|
+
|
|
38
|
+
brand.destroy
|
|
39
|
+
|
|
40
|
+
expect(product.reload.brand_id).to be_nil
|
|
41
|
+
expect(product).to be_persisted
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Prefer `build` over `create` when a record does not need to hit the database — it is markedly faster across a suite.
|
|
47
|
+
|
|
48
|
+
## Step 3: Controller specs
|
|
49
|
+
|
|
50
|
+
Controller specs are where edge cases belong: authorization, scoping, filtering, error shapes. Always `render_views`, so a broken serializer fails the spec rather than passing silently:
|
|
51
|
+
|
|
52
|
+
```ruby server/spec/controllers/spree/api/v3/store/brands_controller_spec.rb
|
|
53
|
+
RSpec.describe Spree::Api::V3::Store::BrandsController, type: :controller do
|
|
54
|
+
render_views
|
|
55
|
+
|
|
56
|
+
it 'exposes public fields only' do
|
|
57
|
+
create(:brand, name: 'Wilson')
|
|
58
|
+
|
|
59
|
+
get :index, params: { format: :json }
|
|
60
|
+
|
|
61
|
+
record = json_response['data'].first
|
|
62
|
+
expect(record['id']).to start_with('brand_')
|
|
63
|
+
expect(record).not_to have_key('created_at')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'filters by name' do
|
|
67
|
+
create(:brand, name: 'Wilson')
|
|
68
|
+
create(:brand, name: 'Penn')
|
|
69
|
+
|
|
70
|
+
get :index, params: { q: { name_cont: 'wil' }, format: :json }
|
|
71
|
+
|
|
72
|
+
expect(json_response['data'].map { |b| b['name'] }).to eq(['Wilson'])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That second example is worth keeping: it proves the Ransack allowlist from step 1 actually permits `name_cont`. Remove `name` from `whitelisted_ransackable_attributes` and this spec fails, which is exactly what you want.
|
|
78
|
+
|
|
79
|
+
> **NOTE:** Specs run against a store the suite sets up for you, available as `@default_store`. Use it rather than creating another store marked as the default — two of them in one run produce failures that surface far from their cause.
|
|
80
|
+
|
|
81
|
+
## Step 4: Dashboard tests
|
|
82
|
+
|
|
83
|
+
The dashboard is a TypeScript app, so its code tests with Vitest:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd apps/dashboard && pnpm test
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Test the client wrappers and any logic in your pages. Mock the HTTP layer rather than the client module, so the test exercises the real request path:
|
|
90
|
+
|
|
91
|
+
```ts apps/dashboard/src/brands/client.test.ts
|
|
92
|
+
import { http, HttpResponse } from 'msw'
|
|
93
|
+
import { setupServer } from 'msw/node'
|
|
94
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
|
|
95
|
+
import { BrandsClient } from './client'
|
|
96
|
+
|
|
97
|
+
const server = setupServer(
|
|
98
|
+
http.get('*/api/v3/admin/brands', () =>
|
|
99
|
+
HttpResponse.json({ data: [{ id: 'brand_k5nR8xLq', name: 'Wilson' }] }),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
beforeAll(() => server.listen())
|
|
104
|
+
afterEach(() => server.resetHandlers())
|
|
105
|
+
afterAll(() => server.close())
|
|
106
|
+
|
|
107
|
+
describe('BrandsClient', () => {
|
|
108
|
+
it('lists brands from the admin endpoint', async () => {
|
|
109
|
+
const result = await BrandsClient.list({ per_page: 10 })
|
|
110
|
+
|
|
111
|
+
expect(result.data).toHaveLength(1)
|
|
112
|
+
expect(result.data[0].name).toBe('Wilson')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Mocking the HTTP layer rather than the client module means the test exercises the real request path — the URL, the params and the response shape — so a wrong endpoint fails here instead of in the browser.
|
|
118
|
+
|
|
119
|
+
## Step 5: One end-to-end pass
|
|
120
|
+
|
|
121
|
+
Everything above tests a layer in isolation. One Playwright spec proves the layers are actually connected — a brand created through the Admin API reaches the dashboard screen you built:
|
|
122
|
+
|
|
123
|
+
```ts apps/dashboard/e2e/brands.spec.ts
|
|
124
|
+
import { expect, test } from '@playwright/test'
|
|
125
|
+
import { createBrand } from './helpers/api'
|
|
126
|
+
import { login } from './helpers/login'
|
|
127
|
+
|
|
128
|
+
test('a brand appears on the brands screen', async ({ page }) => {
|
|
129
|
+
const name = `Wilson ${Date.now()}`
|
|
130
|
+
await createBrand({ name, slug: `wilson-${Date.now()}` })
|
|
131
|
+
|
|
132
|
+
await login(page)
|
|
133
|
+
await page.getByRole('link', { name: /brands/i }).click()
|
|
134
|
+
|
|
135
|
+
await expect(page.getByRole('cell', { name })).toBeVisible()
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Navigating from the sidebar rather than calling `page.goto('/brands')` keeps the spec honest about the real path, which is store-scoped (`/:storeId/brands`) and would otherwise have to be hardcoded.
|
|
140
|
+
|
|
141
|
+
Once you add a create form, replace the API call with the form itself — filling it and asserting the new row is the stronger test, because it covers the write path too.
|
|
142
|
+
|
|
143
|
+
Two conventions matter here:
|
|
144
|
+
|
|
145
|
+
- **Drive the UI, assert on the UI.** Fill labels, click buttons, check visible text. Avoid waiting on API responses — `expect(...).toBeVisible()` polls until the condition holds, which covers nearly every case and keeps the test from breaking when the API shape changes.
|
|
146
|
+
- **Suffix names with `Date.now()`.** Specs share a database, and a fixed name collides with whatever an earlier run left behind.
|
|
147
|
+
|
|
148
|
+
## What to run before you push
|
|
149
|
+
|
|
150
|
+
Run the specs for what you changed rather than the whole suite:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
spree rspec spec/models/spree/brand_spec.rb
|
|
154
|
+
spree rspec spec/controllers/spree/api/v3/
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Pass a file, a directory, or a single example with a line number (`spec/models/spree/brand_spec.rb:12`). Keep the full run for continuous integration, where waiting costs you nothing.
|
|
158
|
+
|
|
159
|
+
## You're done
|
|
160
|
+
|
|
161
|
+
You have built one feature through every layer of Spree:
|
|
162
|
+
|
|
163
|
+
- A model with a Store API and an Admin API, generated in one command
|
|
164
|
+
- A dashboard plugin giving staff a place to manage it
|
|
165
|
+
- Storefront pages rendering it through typed SDK calls
|
|
166
|
+
- Lifecycle events other systems react to
|
|
167
|
+
- Tests at each layer, plus one pass across all of them
|
|
168
|
+
|
|
169
|
+
From here: [customization reference](../customization/quickstart.md) for extending core models and workflows, [dashboard customization](../dashboard/customization/quickstart.md) for deeper plugin work, and [core concepts](../core-concepts/architecture.md) for how the rest of the platform fits together.
|
|
@@ -138,6 +138,22 @@ bundle exec rake spree:stock:recount_levels
|
|
|
138
138
|
|
|
139
139
|
`spree_stock_levels` gains `reserved_count` (units held by checkouts in progress) and `incoming_count` (units on their way on a purchase order placed with a supplier or a transfer in transit). Both are kept by the workflows that change them and start at zero on an existing install; this recomputes them from active reservations and open documents and prints every level it corrected. Until it runs, the dashboard's Inventory page shows zero reserved and incoming for stock that predates the upgrade. Safe to re-run at any time.
|
|
140
140
|
|
|
141
|
+
### Name who performed past actions
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
bundle exec rake spree:upgrade:backfill_actor_types
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Orders, refunds, returns, exchanges, claims and stock receipts remember who
|
|
148
|
+
cancelled, approved, created or received them. In 6.0 that can be a member of
|
|
149
|
+
staff **or an API key**, so each id column — `canceler_id`, `approver_id`,
|
|
150
|
+
`created_by_id`, `refunder_id`, `received_by_id` — gained a type column beside
|
|
151
|
+
it (`canceler_type`, and so on) naming which kind of actor the id points at.
|
|
152
|
+
|
|
153
|
+
Rows written before the upgrade carry an id and an empty type; this task fills
|
|
154
|
+
the type in. Until it runs they still read as the staff member they always
|
|
155
|
+
were, with a deprecation warning. Safe to re-run at any time.
|
|
156
|
+
|
|
141
157
|
## The Cart/Order split
|
|
142
158
|
|
|
143
159
|
The single biggest change. What used to be one `Spree::Order` living through checkout and beyond is now two models:
|
package/package.json
CHANGED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Admin API
|
|
3
|
-
description: Add a ProductFaq endpoint to the Admin API — resource controller conventions, an admin serializer, and a permission catalog entry.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Staff manage FAQs through the Admin API — the same API the dashboard, integrations and scripts use. This chapter adds the endpoint following the conventions every Spree resource follows, which is what makes the dashboard plugin in the next chapter nearly free.
|
|
7
|
-
|
|
8
|
-
## What this chapter covers
|
|
9
|
-
|
|
10
|
-
- The resource controller: full CRUD from one small controller declaring its model, serializer and permitted attributes
|
|
11
|
-
- The admin serializer and prefixed IDs
|
|
12
|
-
- Registering the resource in the permission catalog so roles and API key scopes cover it
|
|
13
|
-
- Exercising the endpoint with the Admin CLI
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Dashboard Plugin
|
|
3
|
-
description: Scaffold a dashboard plugin for ProductFaqs — a table, a form, and navigation, generated by the CLI and wired to the Admin API endpoint.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
With the Admin API in place, the back-office UI is a dashboard plugin: a table listing FAQs, a form to create and edit them, and a navigation entry — scaffolded by the CLI and customized from there.
|
|
7
|
-
|
|
8
|
-
## What this chapter covers
|
|
9
|
-
|
|
10
|
-
- Scaffolding the plugin with the CLI
|
|
11
|
-
- The resource table: columns, search and pagination against the Admin API
|
|
12
|
-
- The form: fields, validation errors mapped from API responses
|
|
13
|
-
- Registering navigation and permissions-aware visibility
|
|
14
|
-
- Running it against your development store
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Model
|
|
3
|
-
description: Generate the ProductFaq model — migration, associations, validations and store scoping — the foundation both tracks build on.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Every feature starts with a model. This chapter generates `ProductFaq` — question, answer, position, attached to a product and scoped to the store.
|
|
7
|
-
|
|
8
|
-
## What this chapter covers
|
|
9
|
-
|
|
10
|
-
- Generating the model and migration from the command line
|
|
11
|
-
- Store scoping — why every store-owned record belongs to a store, and what that buys you
|
|
12
|
-
- The association to products, validations, and ordering
|
|
13
|
-
- Running the migration and creating a record in the console
|