@spree/docs 0.1.156 → 0.1.158
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.
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Validations
|
|
3
|
+
section: customization
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Adding a rule — and occasionally relaxing one Spree ships — is one of the most
|
|
9
|
+
common reasons to reach into core models. There are three ways to do it, and
|
|
10
|
+
picking the right one is mostly a question of *what kind of rule* you have.
|
|
11
|
+
|
|
12
|
+
| Your rule is about… | Use |
|
|
13
|
+
|---|---|
|
|
14
|
+
| Whether an **operation** may happen — a purchase limit, B2B eligibility, a region policy | A workflow [`validate` hook](workflows.md) |
|
|
15
|
+
| The **shape of a record** — a field you added must be present, a format must match | A [decorator](decorators.md) adding a validation |
|
|
16
|
+
| A **new attribute** you need on an existing model | [Custom fields](../core-concepts/metafields.md) |
|
|
17
|
+
|
|
18
|
+
Spree deliberately has no registry for adding or removing arbitrary model
|
|
19
|
+
validations. Adding one with a decorator is already a single line of ordinary
|
|
20
|
+
Rails, and removing a core rule is served by the knobs below.
|
|
21
|
+
|
|
22
|
+
## Vetoing an operation
|
|
23
|
+
|
|
24
|
+
This is the one to reach for first. A `validate` hook runs inside the flow,
|
|
25
|
+
before anything is written, and can stop it:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/initializers/spree.rb
|
|
29
|
+
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
|
|
30
|
+
Spree.hooks.register('carts.upsert_items.validate', 'MyStore::CheckPurchaseLimit')
|
|
31
|
+
|
|
32
|
+
module MyStore
|
|
33
|
+
class CheckPurchaseLimit
|
|
34
|
+
def call(workflow)
|
|
35
|
+
return if workflow.quantity <= 10
|
|
36
|
+
|
|
37
|
+
workflow.errors.add(:quantity, :purchase_limit_exceeded,
|
|
38
|
+
message: 'You can order at most 10 of this item.')
|
|
39
|
+
workflow.reject!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Why this beats a model validation for operational rules: it runs only for the
|
|
46
|
+
operation you targeted, so a data migration or an admin correction isn't fought
|
|
47
|
+
by a customer-facing rule; it sees the whole flow, not one record; and it can
|
|
48
|
+
tell staff from customers (`workflow.created_by`) to allow a supervisor
|
|
49
|
+
override.
|
|
50
|
+
|
|
51
|
+
Both cart keys appear above because adding to a cart and setting a quantity are
|
|
52
|
+
different flows. See [Services & Workflows](workflows.md)
|
|
53
|
+
for the full hook list.
|
|
54
|
+
|
|
55
|
+
## Adding a rule to a model
|
|
56
|
+
|
|
57
|
+
For rules about the shape of a record, a decorator is the supported route:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# app/models/spree/product_decorator.rb
|
|
61
|
+
module MyStore
|
|
62
|
+
module ProductDecorator
|
|
63
|
+
def self.prepended(base)
|
|
64
|
+
base.validates :external_id, presence: true, uniqueness: true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Spree::Product.prepend(MyStore::ProductDecorator)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Decorators couple you to Spree internals, so prefer a hook when the rule is
|
|
73
|
+
really about an operation. See
|
|
74
|
+
[Decorators](decorators.md).
|
|
75
|
+
|
|
76
|
+
## Relaxing a rule Spree ships
|
|
77
|
+
|
|
78
|
+
Never call `clear_validators!` — it wipes every validation on the model,
|
|
79
|
+
including ones added by other extensions, and the breakage appears far from the
|
|
80
|
+
cause. Use one of these instead.
|
|
81
|
+
|
|
82
|
+
### Store preferences
|
|
83
|
+
|
|
84
|
+
Some rules are already switchable, per store, from the dashboard or a console:
|
|
85
|
+
|
|
86
|
+
| Preference | Effect |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `address_requires_phone` | Requires a phone number on addresses |
|
|
89
|
+
| `company_field_enabled` | Shows the company field on address forms |
|
|
90
|
+
| `address_requires_company` | Requires it — only meaningful with the field shown |
|
|
91
|
+
| `disable_sku_validation` | Turns off SKU uniqueness |
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
store.update!(preferred_address_requires_company: true)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Overriding the gating predicate
|
|
98
|
+
|
|
99
|
+
Most conditional validations in `Spree::Address` are gated on a predicate you
|
|
100
|
+
can override, which is far safer than removing the validation:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
module MyStore
|
|
104
|
+
module AddressDecorator
|
|
105
|
+
# Business addresses only in this store.
|
|
106
|
+
def require_company?
|
|
107
|
+
!quick_checkout
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Spree::Address.prepend(MyStore::AddressDecorator)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`require_name?`, `require_street?`, `require_zipcode?`, `require_phone?` and
|
|
116
|
+
`require_company?` all work this way.
|
|
117
|
+
|
|
118
|
+
### The address validator registry
|
|
119
|
+
|
|
120
|
+
Address rules are regional and business-specific, so addresses carry a registry
|
|
121
|
+
of extra validator classes you can add to and remove from:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# config/initializers/spree.rb — or config.to_prepare for your own classes
|
|
125
|
+
Spree.validators.addresses.register(MyStore::PostBoxValidator)
|
|
126
|
+
|
|
127
|
+
# Drop a rule Spree ships
|
|
128
|
+
Spree.validators.addresses.unregister(Spree::Addresses::PhoneValidator)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A validator is an ordinary `ActiveModel::Validator`:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
module MyStore
|
|
135
|
+
class PostBoxValidator < ActiveModel::Validator
|
|
136
|
+
def validate(record)
|
|
137
|
+
return unless record.address1.to_s.match?(/\A\s*P\.?O\.? Box/i)
|
|
138
|
+
|
|
139
|
+
record.errors.add(:address1, :po_box_not_deliverable)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Register your own classes from `config.to_prepare` rather than an initializer —
|
|
146
|
+
the registry holds classes, and a reloadable constant registered once at boot
|
|
147
|
+
goes stale on the next reload.
|
|
148
|
+
|
|
149
|
+
## Checkout requirements
|
|
150
|
+
|
|
151
|
+
Rules about what a cart needs *before it can be completed* — a phone number
|
|
152
|
+
before delivery, a purchase order number for B2B — belong in the checkout
|
|
153
|
+
requirements registry rather than a model validation, because they also drive
|
|
154
|
+
what the storefront shows as outstanding:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
Spree::Checkout::Registry.add_requirement(
|
|
158
|
+
step: 'address',
|
|
159
|
+
field: 'phone',
|
|
160
|
+
message: 'Phone number is required for delivery',
|
|
161
|
+
satisfied: ->(cart) { cart.ship_address&.phone.present? }
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
See the [Spree 6 quickstart](/v6/developer/customization/quickstart) for the full registry API.
|
|
166
|
+
|
|
167
|
+
## Custom field values
|
|
168
|
+
|
|
169
|
+
Custom fields currently validate their type only — a `Number` field rejects
|
|
170
|
+
non-numbers, a `Json` field rejects malformed JSON. There is no per-definition
|
|
171
|
+
length, range or format rule yet. A product type marking a custom field as
|
|
172
|
+
*required* is an advisory marker shown in the dashboard; the server does not
|
|
173
|
+
enforce it.
|
|
@@ -37,7 +37,7 @@ result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantit
|
|
|
37
37
|
if result.success?
|
|
38
38
|
line_item = result.value
|
|
39
39
|
else
|
|
40
|
-
puts result.error.
|
|
40
|
+
puts result.error.to_s
|
|
41
41
|
end
|
|
42
42
|
```
|
|
43
43
|
|
|
@@ -76,12 +76,31 @@ module MyStore
|
|
|
76
76
|
# every #perform keyword is a reader: cart, variant, quantity, ...
|
|
77
77
|
return if workflow.quantity <= 10
|
|
78
78
|
|
|
79
|
-
workflow.
|
|
79
|
+
workflow.errors.add(:quantity, :purchase_limit_exceeded,
|
|
80
|
+
message: 'You can order at most 10 of this item.')
|
|
81
|
+
workflow.reject!
|
|
80
82
|
end
|
|
81
83
|
end
|
|
82
84
|
end
|
|
83
85
|
```
|
|
84
86
|
|
|
87
|
+
`workflow.errors` is an `ActiveModel::Errors`, the same object a model uses. A
|
|
88
|
+
rejection therefore reaches the API in the shape clients already handle for
|
|
89
|
+
validation failures — the field name, a symbolic code, and the message:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"error": {
|
|
94
|
+
"code": "validation_error",
|
|
95
|
+
"message": "Quantity You can order at most 10 of this item.",
|
|
96
|
+
"details": { "quantity": ["You can order at most 10 of this item."] }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Add to `:base` for a rejection that isn't about one field. `reject!('message')`
|
|
102
|
+
with an argument is shorthand for exactly that.
|
|
103
|
+
|
|
85
104
|
> **NOTE:** Hook keys are validated at boot. Registering against a hook that doesn't exist
|
|
86
105
|
> raises `Spree::Hooks::UnknownHookError` with the list of valid hooks for that
|
|
87
106
|
> workflow, so a typo fails immediately instead of silently never firing.
|
|
@@ -179,9 +198,10 @@ The caller receives a normal failure result — no exception reaches your
|
|
|
179
198
|
controller:
|
|
180
199
|
|
|
181
200
|
```ruby
|
|
182
|
-
result = Spree.
|
|
201
|
+
result = Spree.return_create_workflow.call(order: order, items: items)
|
|
183
202
|
result.success? # => false
|
|
184
|
-
result.error.
|
|
203
|
+
result.error.to_s # => "This order is outside the 30-day return window."
|
|
204
|
+
result.error.value # => ActiveModel::Errors — the rejection, field by field
|
|
185
205
|
```
|
|
186
206
|
|
|
187
207
|
> **WARNING:** Reject from `validate` hooks, not from `after_*` hooks. Rejecting late still
|
|
@@ -227,6 +247,8 @@ is reported through `Rails.error` so it's visible rather than mysterious.
|
|
|
227
247
|
|---|---|---|---|
|
|
228
248
|
| `carts.add_item` | `validate` | validate | Before the line item is built |
|
|
229
249
|
| `carts.add_item` | `after_item_added` | lifecycle | After the item is saved and totals recalculated (in transaction) |
|
|
250
|
+
| `carts.upsert_items` | `validate` | validate | Once per item, immediately before that item is applied — quantity edits, removals and bulk payloads. Earlier items in the batch may already be written |
|
|
251
|
+
| `carts.upsert_items` | `after_items_upserted` | lifecycle | After the batch is applied and the cart recalculated once (in transaction) |
|
|
230
252
|
| `carts.complete` | `validate` | validate | After checkout requirements pass, before the order is created |
|
|
231
253
|
| `carts.complete` | `before_finalize` | lifecycle | After payment, before the order is placed |
|
|
232
254
|
| `carts.complete` | `after_finalize` | lifecycle | After the order is placed |
|
|
@@ -251,13 +273,33 @@ is reported through `Rails.error` so it's visible rather than mysterious.
|
|
|
251
273
|
| `payments.handle_webhook` | `after_handle` | lifecycle | After the gateway callback is processed |
|
|
252
274
|
| `customers.create` | `validate` | validate | After the customer is built, before it is saved — the registration-policy veto (bot screening, B2B approval) |
|
|
253
275
|
| `customers.create` | `after_create` | lifecycle | After the customer is created and the newsletter subscriber linked |
|
|
276
|
+
| `products.create` | `validate` | validate | After the product is built, before it is saved — fires for the Admin API, CSV imports and seeds alike |
|
|
277
|
+
| `products.create` | `after_create` | lifecycle | After the product is saved (in transaction) |
|
|
278
|
+
| `products.update` | `validate` | validate | With the pending attributes assigned, so `product.changes` describes the edit |
|
|
279
|
+
| `products.update` | `after_update` | lifecycle | After the product is saved (in transaction) |
|
|
280
|
+
| `products.destroy` | `validate` | validate | Before the product is soft-deleted — refuse a deletion your store shouldn't allow |
|
|
281
|
+
| `products.destroy` | `after_destroy` | lifecycle | After the soft-delete, for host cleanup (in transaction) |
|
|
254
282
|
|
|
255
283
|
`before_cancel` and `before_resume` accept `reject!` like a `validate` hook.
|
|
256
284
|
|
|
257
285
|
Draft-order editing in the admin uses **twin workflows** with their own keys —
|
|
258
|
-
`orders.add_item`, `orders.
|
|
259
|
-
the same hooks as their cart
|
|
260
|
-
|
|
286
|
+
`orders.add_item`, `orders.upsert_items`, `orders.recalculate`,
|
|
287
|
+
`orders.recalculate_totals` — carrying the same hooks as their cart
|
|
288
|
+
counterparts. Register against the cart key for storefront carts, the order key
|
|
289
|
+
for admin edits, or both.
|
|
290
|
+
|
|
291
|
+
> **NOTE:** `carts.add_item` **adds** to a quantity; `carts.upsert_items` **sets** it, and
|
|
292
|
+
> is what a quantity edit, a removal (quantity `0`) and a bulk item payload all
|
|
293
|
+
> run through. A rule about what may be in a cart therefore belongs on both keys —
|
|
294
|
+
> the readers are the same (`cart`, `variant`, `quantity`), so one handler class
|
|
295
|
+
> registers against each.
|
|
296
|
+
>
|
|
297
|
+
> `upsert_items` is also the one flow where a rejection is **not** fatal on the
|
|
298
|
+
> storefront: the vetoed item is skipped, the rest of the batch applies, and what
|
|
299
|
+
> was dropped comes back in the cart's `warnings`. A customer restoring a saved
|
|
300
|
+
> cart keeps whatever is still purchasable. Admin order edits behave the opposite
|
|
301
|
+
> way — the whole edit fails — because a silently dropped row is worse than a
|
|
302
|
+
> failed request when a merchant is editing.
|
|
261
303
|
|
|
262
304
|
Inspect what's available at runtime:
|
|
263
305
|
|
|
@@ -340,7 +382,7 @@ The whole vocabulary:
|
|
|
340
382
|
| `on_flow_failure: :name` | Names the undo for a step, run in reverse if a later step fails |
|
|
341
383
|
| `run_hooks :name` | Dispatches a declared hook; returns the merged hash from context handlers |
|
|
342
384
|
| `failure(value, error)` | Aborts the flow — rolls back an open transaction and returns a failure result |
|
|
343
|
-
| `reject
|
|
385
|
+
| `reject!` | The same, named for hook handlers vetoing a flow — carries `workflow.errors` |
|
|
344
386
|
| `halt!(value)` | Successful early exit (not valid inside a transaction the workflow opened) |
|
|
345
387
|
| `hooks :a, :b` | Declares the extension points this workflow dispatches |
|
|
346
388
|
|