@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.value
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.reject!('You can order at most 10 of this item.')
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.cart_add_item_workflow.call(cart: cart, variant: variant)
201
+ result = Spree.return_create_workflow.call(order: order, items: items)
183
202
  result.success? # => false
184
- result.error.value # => "You can order at most 10 of this item."
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.recalculate`, `orders.recalculate_totals` — carrying
259
- the same hooks as their cart counterparts. Register against the cart key for
260
- storefront carts, the order key for admin edits, or both.
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!(message)` | The same, named for hook handlers vetoing a flow |
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/docs",
3
- "version": "0.1.156",
3
+ "version": "0.1.158",
4
4
  "description": "Spree Commerce developer documentation for AI agents and local reference",
5
5
  "type": "module",
6
6
  "license": "CC-BY-4.0",