Skip to content

Commit ca16162

Browse files
authored
Merge pull request #6358 from mamhoff/add-yard-docs-to-solidus-promotions-condition
Add yard docs to solidus promotions condition
2 parents 06ebabf + f33f6c7 commit ca16162

File tree

2 files changed

+167
-3
lines changed

2 files changed

+167
-3
lines changed

promotions/app/models/solidus_promotions/condition.rb

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,48 @@
11
# frozen_string_literal: true
22

33
module SolidusPromotions
4+
# Base class for all promotion conditions.
5+
#
6+
# Conditions determine whether a promotion is eligible to be applied to a specific
7+
# promotable object (such as an order or line item). Each condition subclass implements
8+
# the eligibility logic and specifies what type of objects it can be applied to.
9+
#
10+
# Conditions work at different levels:
11+
# - Order-level conditions (include OrderLevelCondition): Check entire orders
12+
# - Line item-level conditions (include LineItemLevelCondition): Check individual line items
13+
# - Hybrid conditions (include LineItemApplicableOrderLevelCondition): Check orders but can also
14+
# filter which line items are eligible
15+
#
16+
# @abstract Subclass and override {#applicable?} and {#eligible?} to implement
17+
# a custom condition.
18+
#
19+
# @example Creating an order-level condition
20+
# class MinimumPurchaseCondition < Condition
21+
# include OrderLevelCondition
22+
#
23+
# preference :minimum_amount, :decimal, default: 50.00
24+
#
25+
# def eligible?(order, _options = {})
26+
# if order.item_total < preferred_minimum_amount
27+
# eligibility_errors.add(:base, "Order total too low")
28+
# end
29+
# eligibility_errors.empty?
30+
# end
31+
# end
32+
#
33+
# @example Creating a line item-level condition
34+
# class SpecificProductCondition < Condition
35+
# include LineItemLevelCondition
36+
#
37+
# preference :product_id, :integer
38+
#
39+
# def eligible?(line_item, _options = {})
40+
# if line_item.product_id != preferred_product_id
41+
# eligibility_errors.add(:base, "Wrong product")
42+
# end
43+
# eligibility_errors.empty?
44+
# end
45+
# end
446
class Condition < Spree::Base
547
include Spree::Preferences::Persistable
648

@@ -12,46 +54,150 @@ class Condition < Spree::Base
1254
validate :unique_per_benefit, on: :create
1355
validate :possible_condition_for_benefit, if: -> { benefit.present? }
1456

57+
# Returns relations that should be preloaded for this condition.
58+
#
59+
# Override this method in subclasses to specify associations that should be eager loaded
60+
# to avoid N+1 queries when evaluating conditions.
61+
#
62+
# @return [Array<Symbol>] An array of association names to preload
63+
#
64+
# @example Preloading products association
65+
# def preload_relations
66+
# [:products]
67+
# end
1568
def preload_relations
1669
[]
1770
end
1871

72+
# Determines if this condition can be applied to a given promotable object.
73+
#
74+
# This method is typically implemented by including one of the level modules
75+
# (OrderLevelCondition, LineItemLevelCondition, or LineItemApplicableOrderLevelCondition)
76+
# rather than being overridden directly.
77+
#
78+
# @param _promotable [Object] The object to check (e.g., Spree::Order, Spree::LineItem)
79+
#
80+
# @return [Boolean] true if this condition applies to the promotable type
81+
#
82+
# @raise [NotImplementedError] if not implemented in subclass
83+
#
84+
# @example Order-level condition applicability
85+
# condition.applicable?(order) # => true
86+
# condition.applicable?(line_item) # => false
87+
#
88+
# @see OrderLevelCondition
89+
# @see LineItemLevelCondition
90+
# @see LineItemApplicableOrderLevelCondition
1991
def applicable?(_promotable)
20-
raise NotImplementedError, "applicable? should be implemented in a sub-class of SolidusPromotions::Rule"
92+
raise NotImplementedError, "applicable? should be implemented in a sub-class of SolidusPromotions::Condition"
2193
end
2294

95+
# Determines if the promotable object meets this condition's eligibility requirements.
96+
#
97+
# This is the core method that implements the condition's logic. When the promotable
98+
# is not eligible, this method should add errors to {#eligibility_errors} explaining why.
99+
#
100+
# @param _promotable [Object] The object to evaluate (e.g., Spree::Order, Spree::LineItem)
101+
# @param _options [Hash] Additional options for eligibility checking
102+
#
103+
# @return [Boolean] true if the promotable meets the condition, false otherwise
104+
#
105+
# @raise [NotImplementedError] if not implemented in subclass
106+
#
107+
# @example Order total condition
108+
# def eligible?(order, _options = {})
109+
# if order.item_total < preferred_minimum
110+
# eligibility_errors.add(:base, "Order total too low")
111+
# end
112+
# eligibility_errors.empty?
113+
# end
114+
#
115+
# @example First order condition
116+
# def eligible?(order, _options = {})
117+
# if order.user.orders.complete.count > 1
118+
# eligibility_errors.add(:base, "Not first order")
119+
# end
120+
# eligibility_errors.empty?
121+
# end
122+
#
123+
# @see #eligibility_errors
23124
def eligible?(_promotable, _options = {})
24-
raise NotImplementedError, "eligible? should be implemented in a sub-class of SolidusPromotions::Rule"
125+
raise NotImplementedError, "eligible? should be implemented in a sub-class of SolidusPromotions::Condition"
25126
end
26127

27128
def level
28-
raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Rule"
129+
raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Condition"
29130
end
30131

132+
# Returns an errors object for tracking eligibility failures.
133+
#
134+
# When {#eligible?} determines that a promotable doesn't meet the condition,
135+
# it should add descriptive errors to this object. These errors are used to
136+
# provide feedback about why a promotion isn't being applied.
137+
#
138+
# @return [ActiveModel::Errors] An errors collection for this condition
139+
#
140+
# @example Adding an eligibility error
141+
# def eligible?(order, _options = {})
142+
# if order.item_total < 50
143+
# eligibility_errors.add(:base, "Minimum order is $50", error_code: :item_total_too_low)
144+
# end
145+
# eligibility_errors.empty?
146+
# end
31147
def eligibility_errors
32148
@eligibility_errors ||= ActiveModel::Errors.new(self)
33149
end
34150

151+
# Returns the partial path for rendering this condition in the admin interface.
152+
#
153+
# @return [String] The path to the admin form partial for this condition
154+
#
155+
# @example
156+
# # For SolidusPromotions::Conditions::ItemTotal
157+
# # => "solidus_promotions/admin/condition_fields/item_total"
35158
def to_partial_path
36159
"solidus_promotions/admin/condition_fields/#{model_name.element}"
37160
end
38161

162+
# Determines if this condition can be updated in the admin interface.
163+
#
164+
# A condition is considered updateable if it has any preferences that can be configured.
165+
#
166+
# @return [Boolean] true if the condition has configurable preferences
39167
def updateable?
40168
preferences.any?
41169
end
42170

43171
private
44172

173+
# Validates that only one instance of this condition type exists per benefit.
174+
#
175+
# Prevents duplicate conditions of the same type from being added to a single benefit.
45176
def unique_per_benefit
46177
return unless self.class.exists?(benefit_id: benefit_id, type: self.class.name)
47178

48179
errors.add(:benefit, :already_contains_condition_type)
49180
end
50181

182+
# Validates that this condition type is allowed for the associated benefit.
183+
#
184+
# Checks the benefit's {Benefit#possible_conditions} to ensure this condition
185+
# type is compatible.
51186
def possible_condition_for_benefit
52187
benefit.possible_conditions.include?(self.class) || errors.add(:type, :invalid_condition_type)
53188
end
54189

190+
# Generates a translated eligibility error message.
191+
#
192+
# Looks up the error message in the I18n translations under the condition's scope.
193+
#
194+
# @param key [Symbol] The I18n key for the error message
195+
# @param options [Hash] Interpolation options for the message
196+
#
197+
# @return [String] The translated error message
198+
#
199+
# @example
200+
# eligibility_error_message(:item_total_too_low, minimum: "$50")
55201
def eligibility_error_message(key, options = {})
56202
I18n.t(key, scope: [:solidus_promotions, :eligibility_errors, self.class.name.underscore], **options)
57203
end

promotions/spec/models/solidus_promotions/condition_spec.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ def self.model_name
1414
def eligible?(_promotable, _options = {})
1515
true
1616
end
17+
18+
def applicable?(_promotable)
19+
true
20+
end
21+
22+
def level
23+
:line_item
24+
end
1725
end
1826
end
1927

@@ -37,6 +45,16 @@ def eligible?(_promotable, _options = {})
3745
expect { test_condition_class.new.eligible?("promotable") }.not_to raise_error
3846
end
3947

48+
it "forces developer to implement #applicable?" do
49+
expect { bad_test_condition_class.new.applicable?("promotable") }.to raise_error NotImplementedError
50+
expect { test_condition_class.new.applicable?("promotable") }.not_to raise_error
51+
end
52+
53+
it "forces developer to implement #level", :silence_deprecations do
54+
expect { bad_test_condition_class.new.level }.to raise_error NotImplementedError
55+
expect { test_condition_class.new.level }.not_to raise_error
56+
end
57+
4058
it "validates unique conditions for a promotion benefit" do
4159
# Because of Rails' STI, we can't use the anonymous class here
4260
promotion = create(:solidus_promotion, :with_adjustable_benefit)

0 commit comments

Comments
 (0)