magick-feature-flags 1.2.3 → 1.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc65d262824128014a4e5e631001687be36d698efbc1adc5f222685d96b10613
4
- data.tar.gz: d2e15a3828b13071ee573a9c6356215e311012d16d55817a0fdb2aeee51a7172
3
+ metadata.gz: a34f03026e8631bc2d29c0935cb98e472e7ed387fc4628edc357776423bfc2c5
4
+ data.tar.gz: d87af21843efb22d5dba60d342f3beb5fd807eb42f1b2948c82ae8dc3d6b67a2
5
5
  SHA512:
6
- metadata.gz: 6a932f9c887525e88dfe8e6392803a90dee47a9891fb6fa04c2c18561b9203518442d8b46590a20d85b32a2c1d5d75005686f652ed24495b90747aca44e4e4ee
7
- data.tar.gz: 40ac9092db1747cc50e79c94045ba80118274fcd198b57c70a6ad0c17e4b693578ae12f785d152d148684bf9b365e001a51b338174e47d2f1db2dcb5a74b830b
6
+ metadata.gz: '0296397245455dbecd448a2dab448b2209579f4c9c39a1e93b424e8f6982fe8a3f498140ac7af5f1127c70fc2cdaa3378194135afe2e55fe9254646a4c761e64'
7
+ data.tar.gz: 43e1ce6dcf51815f83ccf4440845d415f8d351e6a6038c8f7bcd0a294dfc0def6531a04a118119d66e71a64d7cfcc1607273eda0c181dfdf951c9ee61d4cfae7
data/README.md CHANGED
@@ -5,6 +5,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
5
5
  ## Features
6
6
 
7
7
  - **Multiple Feature Types**: Boolean, string, and number feature flags
8
+ - **A/B Testing**: Built-in experiment support with deterministic, weighted variant assignment
8
9
  - **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
9
10
  - **Exclusions**: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
10
11
  - **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
@@ -359,6 +360,16 @@ string_feature :api_version, default: "v1", description: "API version"
359
360
  # Number features
360
361
  number_feature :max_results, default: 10, description: "Maximum results per page"
361
362
 
363
+ # A/B test experiment
364
+ experiment :checkout_button,
365
+ name: "Checkout Button",
366
+ description: "Button color experiment",
367
+ variants: [
368
+ { name: 'control', value: '#0066cc', weight: 50 },
369
+ { name: 'green', value: '#00cc66', weight: 30 },
370
+ { name: 'red', value: '#cc0000', weight: 20 }
371
+ ]
372
+
362
373
  # With status
363
374
  feature :experimental_feature,
364
375
  type: :boolean,
@@ -405,20 +416,91 @@ end
405
416
 
406
417
  ### Advanced Features
407
418
 
408
- #### Feature Variants (A/B Testing)
419
+ #### A/B Testing (Experiments)
420
+
421
+ Magick has built-in support for A/B testing with deterministic variant assignment. The same user always gets the same variant (based on MD5 hashing), ensuring consistent experiences.
422
+
423
+ **Quick setup with DSL (`config/features.rb`):**
424
+
425
+ ```ruby
426
+ experiment :checkout_button,
427
+ name: "Checkout Button Color",
428
+ description: "Test which button color converts better",
429
+ variants: [
430
+ { name: 'control', value: '#0066cc', weight: 50 },
431
+ { name: 'green', value: '#00cc66', weight: 30 },
432
+ { name: 'red', value: '#cc0000', weight: 20 }
433
+ ]
434
+ ```
435
+
436
+ **Or set up programmatically:**
409
437
 
410
438
  ```ruby
411
- feature = Magick[:button_color]
439
+ feature = Magick[:checkout_button]
412
440
  feature.set_variants([
413
- { name: 'blue', value: '#0066cc', weight: 50 },
414
- { name: 'green', value: '#00cc66', weight: 30 },
415
- { name: 'red', value: '#cc0000', weight: 20 }
441
+ { name: 'control', value: '#0066cc', weight: 50 },
442
+ { name: 'green', value: '#00cc66', weight: 30 },
443
+ { name: 'red', value: '#cc0000', weight: 20 }
416
444
  ])
445
+ ```
417
446
 
418
- variant = feature.get_variant
419
- # Returns 'blue', 'green', or 'red' based on weights
447
+ **Usage in your application:**
448
+
449
+ ```ruby
450
+ # Get the variant name for a user (deterministic — same user always gets same variant)
451
+ variant = Magick.variant(:checkout_button, user_id: current_user.id)
452
+ # => "control", "green", or "red"
453
+
454
+ # Get the variant value directly
455
+ color = Magick.variant_value(:checkout_button, user_id: current_user.id)
456
+ # => "#0066cc", "#00cc66", or "#cc0000"
457
+
458
+ # Works with user objects too
459
+ variant = Magick.variant(:checkout_button, user: current_user)
460
+
461
+ # Use in views/controllers
462
+ class CheckoutController < ApplicationController
463
+ def show
464
+ @button_color = Magick.variant_value(:checkout_button, user: current_user)
465
+ # Same user always sees the same color
466
+ end
467
+ end
420
468
  ```
421
469
 
470
+ **Experiments without a user (anonymous visitors):**
471
+
472
+ For flows where there's no authenticated user yet (e.g., registration, landing pages), use any stable identifier as `user_id` — a session ID or a tracking cookie:
473
+
474
+ ```ruby
475
+ class RegistrationController < ApplicationController
476
+ def new
477
+ cookies[:visitor_id] ||= SecureRandom.uuid
478
+ @variant = Magick.variant(:registration_flow, user_id: cookies[:visitor_id])
479
+ end
480
+ end
481
+ ```
482
+
483
+ The hashing just needs a consistent string. As long as the same visitor sends the same identifier, they get the same variant every time.
484
+
485
+ **Safe to call on non-existent experiments:**
486
+
487
+ ```ruby
488
+ Magick.variant(:nonexistent, user_id: 123) # => nil
489
+ Magick.variant_value(:nonexistent, user_id: 123) # => nil
490
+ ```
491
+
492
+ **Important — changing weights may shift users:**
493
+
494
+ You can change variant weights at any time via the Admin UI or code, and changes take effect immediately across all adapters. However, changing weights alters the bucket boundaries, which means some users may be reassigned to a different variant after the update. Magick does not persist individual user-to-variant assignments — assignment is computed on the fly from the hash. If your experiment requires that users never shift variants mid-experiment, you should persist the assignment externally (e.g., store `user_id → variant` in a database table on first exposure).
495
+
496
+ **How it works:**
497
+ - Variants are assigned using a deterministic MD5 hash of `feature_name + user_id`
498
+ - The same user always gets the same variant across sessions and requests
499
+ - Weights control the distribution (e.g., 50/30/20 means ~50% control, ~30% green, ~20% red)
500
+ - If no `user_id` is provided, falls back to random assignment (useful for anonymous users)
501
+ - Experiments are boolean features with variants — they work with all targeting and exclusion rules
502
+ - Manage variants through the Admin UI with visual weight distribution
503
+
422
504
  #### Feature Dependencies
423
505
 
424
506
  ```ruby
@@ -676,6 +758,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
676
758
  - **Exclusions**: Exclude users, roles, and tags from a feature (exclusions override inclusions)
677
759
  - **Visual Display**: See all active targeting rules with badges
678
760
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
761
+ - **A/B Test Management**: Create and manage experiment variants with visual weight distribution
679
762
  - **Statistics**: View performance metrics and usage statistics for each feature
680
763
  - **Feature Grouping**: Organize features into groups for easier management and filtering
681
764
  - **Filtering**: Filter features by group, name, or description
@@ -749,6 +832,7 @@ The Admin UI provides the following routes:
749
832
  - `PUT /magick/features/:id/enable_for_role` - Enable feature for specific role
750
833
  - `PUT /magick/features/:id/disable_for_role` - Disable feature for specific role
751
834
  - `PUT /magick/features/:id/update_targeting` - Update targeting rules (roles and users)
835
+ - `PUT /magick/features/:id/update_variants` - Update A/B test variants
752
836
  - `GET /magick/stats/:id` - View feature statistics
753
837
 
754
838
  **Security:**
@@ -7,7 +7,7 @@ module Magick
7
7
  include Magick::AdminUI::Engine.routes.url_helpers
8
8
  layout 'application'
9
9
  before_action :authenticate_admin!
10
- before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting]
10
+ before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting update_variants]
11
11
 
12
12
  # Make route helpers available in views via magick_admin_ui helper
13
13
  helper_method :magick_admin_ui, :available_roles, :available_tags, :partially_enabled?
@@ -80,8 +80,11 @@ module Magick
80
80
  end
81
81
 
82
82
  def enable
83
- @feature.enable
84
- redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
83
+ if @feature.enable
84
+ redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
85
+ else
86
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Cannot enable feature — its dependencies must be enabled first'
87
+ end
85
88
  end
86
89
 
87
90
  def disable
@@ -267,6 +270,35 @@ module Magick
267
270
  redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating targeting: #{e.message}"
268
271
  end
269
272
 
273
+ def update_variants
274
+ variants_data = []
275
+
276
+ if params[:variants].present?
277
+ params[:variants].each do |_index, variant_params|
278
+ next if variant_params[:name].blank?
279
+
280
+ variants_data << {
281
+ name: variant_params[:name].strip,
282
+ value: variant_params[:value].to_s.strip,
283
+ weight: (variant_params[:weight].presence || 0).to_f
284
+ }
285
+ end
286
+ end
287
+
288
+ if variants_data.any?
289
+ @feature.set_variants(variants_data)
290
+ else
291
+ # Clear variants if all removed
292
+ @feature.instance_variable_get(:@targeting)&.delete(:variants)
293
+ @feature.save_targeting
294
+ end
295
+
296
+ redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
297
+ rescue StandardError => e
298
+ Rails.logger.error "Magick: Error updating variants: #{e.message}" if defined?(Rails)
299
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating variants: #{e.message}"
300
+ end
301
+
270
302
  private
271
303
 
272
304
  def authenticate_admin!
@@ -1291,6 +1291,50 @@
1291
1291
  searchInput.addEventListener('input', filterFeatures);
1292
1292
  if (groupSelect) groupSelect.addEventListener('change', filterFeatures);
1293
1293
  })();
1294
+
1295
+ // Variants management
1296
+ (function() {
1297
+ const addBtn = document.getElementById('add_variant_btn');
1298
+ const container = document.getElementById('variants_container');
1299
+ if (!addBtn || !container) return;
1300
+
1301
+ let variantIndex = container.querySelectorAll('.variant-row').length;
1302
+
1303
+ addBtn.addEventListener('click', function() {
1304
+ const noMsg = document.getElementById('no_variants_message');
1305
+ if (noMsg) noMsg.remove();
1306
+
1307
+ const row = document.createElement('div');
1308
+ row.className = 'variant-row';
1309
+ row.setAttribute('data-variant-index', variantIndex);
1310
+ row.innerHTML = '<div style="display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 12px; align-items: end; margin-bottom: 12px; padding: 12px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm);">' +
1311
+ '<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Name</label>' +
1312
+ '<input type="text" name="variants[' + variantIndex + '][name]" placeholder="e.g., control" required></div>' +
1313
+ '<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Value</label>' +
1314
+ '<input type="text" name="variants[' + variantIndex + '][value]" placeholder="e.g., #0066cc"></div>' +
1315
+ '<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Weight</label>' +
1316
+ '<input type="number" name="variants[' + variantIndex + '][weight]" value="0" min="0" max="1000" style="width: 80px;"></div>' +
1317
+ '<div style="padding-bottom: 2px;"><button type="button" class="btn btn-ghost btn-sm remove-variant-btn" onclick="this.closest(\'.variant-row\').remove(); updateVariantIndices();" title="Remove variant" style="color: var(--color-danger);">&times;</button></div>' +
1318
+ '</div>';
1319
+ container.appendChild(row);
1320
+ variantIndex++;
1321
+ });
1322
+ })();
1323
+
1324
+ function updateVariantIndices() {
1325
+ const container = document.getElementById('variants_container');
1326
+ if (!container) return;
1327
+ const rows = container.querySelectorAll('.variant-row');
1328
+ rows.forEach(function(row, i) {
1329
+ row.setAttribute('data-variant-index', i);
1330
+ row.querySelectorAll('input').forEach(function(input) {
1331
+ const name = input.getAttribute('name');
1332
+ if (name) {
1333
+ input.setAttribute('name', name.replace(/variants\[\d+\]/, 'variants[' + i + ']'));
1334
+ }
1335
+ });
1336
+ });
1337
+ }
1294
1338
  </script>
1295
1339
  </body>
1296
1340
  </html>
@@ -278,3 +278,55 @@
278
278
  <% end %>
279
279
  </div>
280
280
  </div>
281
+
282
+ <div class="card" id="variants-section">
283
+ <div class="card-header">
284
+ <h2>A/B Test Variants</h2>
285
+ </div>
286
+ <div class="card-body">
287
+ <%= form_with url: magick_admin_ui.update_variants_feature_path(@feature.name), method: :put, local: true do |f| %>
288
+ <p style="font-size: 13px; color: var(--color-text-secondary); margin-bottom: 16px;">Define experiment variants with weights. Users are deterministically assigned to variants based on their user ID.</p>
289
+
290
+ <% current_variants = (@feature.instance_variable_get(:@targeting) || {})[:variants] || [] %>
291
+
292
+ <div id="variants_container">
293
+ <% if current_variants.any? %>
294
+ <% current_variants.each_with_index do |variant, index| %>
295
+ <div class="variant-row" data-variant-index="<%= index %>">
296
+ <div style="display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 12px; align-items: end; margin-bottom: 12px; padding: 12px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm);">
297
+ <div>
298
+ <label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Name</label>
299
+ <input type="text" name="variants[<%= index %>][name]" value="<%= variant[:name] || variant['name'] %>" placeholder="e.g., control" required>
300
+ </div>
301
+ <div>
302
+ <label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Value</label>
303
+ <input type="text" name="variants[<%= index %>][value]" value="<%= variant[:value] || variant['value'] %>" placeholder="e.g., #0066cc">
304
+ </div>
305
+ <div>
306
+ <label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Weight</label>
307
+ <input type="number" name="variants[<%= index %>][weight]" value="<%= (variant[:weight] || variant['weight'] || 0).to_i %>" min="0" max="1000" style="width: 80px;">
308
+ </div>
309
+ <div style="padding-bottom: 2px;">
310
+ <button type="button" class="btn btn-ghost btn-sm remove-variant-btn" onclick="this.closest('.variant-row').remove(); updateVariantIndices();" title="Remove variant" style="color: var(--color-danger);">&times;</button>
311
+ </div>
312
+ </div>
313
+ </div>
314
+ <% end %>
315
+ <% else %>
316
+ <p id="no_variants_message" style="color: var(--color-text-muted); font-size: 13px; margin-bottom: 16px;">No variants configured. Add variants to create an A/B test.</p>
317
+ <% end %>
318
+ </div>
319
+
320
+ <div style="margin-bottom: 20px;">
321
+ <button type="button" class="btn btn-secondary btn-sm" id="add_variant_btn">+ Add Variant</button>
322
+ </div>
323
+
324
+ <div style="padding-top: 20px; border-top: 1px solid var(--color-border);">
325
+ <div class="btn-group">
326
+ <%= submit_tag 'Save Variants', class: 'btn btn-primary' %>
327
+ <a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary">Cancel</a>
328
+ </div>
329
+ </div>
330
+ <% end %>
331
+ </div>
332
+ </div>
@@ -76,7 +76,9 @@
76
76
  <td data-label="Value">
77
77
  <% if feature.type == :boolean %>
78
78
  <% targeting = feature.instance_variable_get(:@targeting) || {} %>
79
- <% if targeting.any? %>
79
+ <% if targeting[:variants].present? %>
80
+ <span class="toggle-indicator" style="background: var(--color-primary); color: #fff;">A/B</span>
81
+ <% elsif targeting.any? %>
80
82
  <span class="toggle-indicator toggle-partial">PARTIAL</span>
81
83
  <% elsif feature.enabled? %>
82
84
  <span class="toggle-indicator toggle-on">ON</span>
@@ -138,6 +138,52 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
+ <% variants = (@feature.instance_variable_get(:@targeting) || {})[:variants] %>
142
+ <% if variants.present? && variants.any? %>
143
+ <div class="card">
144
+ <div class="card-header">
145
+ <h2>A/B Test Variants</h2>
146
+ <a href="<%= magick_admin_ui.edit_feature_path(@feature.name) %>#variants-section" class="link-subtle">Manage</a>
147
+ </div>
148
+ <div class="card-body" style="padding: 0;">
149
+ <table>
150
+ <thead>
151
+ <tr>
152
+ <th>Variant</th>
153
+ <th>Value</th>
154
+ <th style="text-align: right;">Weight</th>
155
+ <th style="text-align: right;">Distribution</th>
156
+ </tr>
157
+ </thead>
158
+ <tbody>
159
+ <% total_weight = variants.sum { |v| (v[:weight] || v['weight'] || 0).to_f } %>
160
+ <% variants.each do |variant| %>
161
+ <% v_name = variant[:name] || variant['name'] %>
162
+ <% v_value = variant[:value] || variant['value'] %>
163
+ <% v_weight = (variant[:weight] || variant['weight'] || 0).to_f %>
164
+ <% percentage = total_weight > 0 ? (v_weight / total_weight * 100).round(1) : 0 %>
165
+ <tr>
166
+ <td>
167
+ <span style="font-weight: 600;"><%= v_name %></span>
168
+ </td>
169
+ <td><code><%= v_value %></code></td>
170
+ <td style="text-align: right; font-variant-numeric: tabular-nums;"><%= v_weight.to_i %></td>
171
+ <td style="text-align: right;">
172
+ <div style="display: flex; align-items: center; justify-content: flex-end; gap: 8px;">
173
+ <div style="width: 80px; height: 6px; background: var(--color-border); border-radius: 3px; overflow: hidden;">
174
+ <div style="width: <%= percentage %>%; height: 100%; background: var(--color-primary); border-radius: 3px;"></div>
175
+ </div>
176
+ <span style="font-size: 12px; font-weight: 500; color: var(--color-text-secondary); min-width: 40px; text-align: right;"><%= percentage %>%</span>
177
+ </div>
178
+ </td>
179
+ </tr>
180
+ <% end %>
181
+ </tbody>
182
+ </table>
183
+ </div>
184
+ </div>
185
+ <% end %>
186
+
141
187
  <% dependencies = @feature.dependencies %>
142
188
  <% if dependencies.any? %>
143
189
  <div class="card">
data/config/routes.rb CHANGED
@@ -10,6 +10,7 @@ Magick::AdminUI::Engine.routes.draw do
10
10
  put :enable_for_role
11
11
  put :disable_for_role
12
12
  put :update_targeting
13
+ put :update_variants
13
14
  end
14
15
  end
15
16
  resources :stats, only: [:show]
data/lib/magick/dsl.rb CHANGED
@@ -19,6 +19,15 @@ module Magick
19
19
  Magick.register_feature(name, type: :number, default_value: default, **options)
20
20
  end
21
21
 
22
+ def experiment(name, variants:, **options)
23
+ feature = Magick.register_feature(name, type: :boolean, default_value: false, **options)
24
+ variant_data = variants.map do |v|
25
+ { name: v[:name].to_s, value: v[:value], weight: (v[:weight] || 0).to_f }
26
+ end
27
+ feature.set_variants(variant_data)
28
+ feature
29
+ end
30
+
22
31
  # Targeting DSL methods
23
32
  def enable_for_user(feature_name, user_id)
24
33
  Magick[feature_name].enable_for_user(user_id)
@@ -442,34 +442,38 @@ module Magick
442
442
  return nil unless targeting[:variants]
443
443
 
444
444
  variants = targeting[:variants]
445
- selected_variant = if variants.length == 1
446
- variants.first[:name]
447
- else
448
- # Weighted random selection
449
- total_weight = variants.sum { |v| v[:weight] || 0 }
450
- if total_weight.zero?
451
- variants.first[:name]
452
- else
453
- random = rand(total_weight)
454
- current = 0
455
- selected = nil
456
- variants.each do |variant|
457
- current += variant[:weight] || 0
458
- if random < current
459
- selected = variant[:name]
460
- break
461
- end
462
- end
463
- selected || variants.first[:name]
464
- end
465
- end
445
+ return nil if variants.empty?
446
+ return variants.first[:name] if variants.length == 1
447
+
448
+ total_weight = variants.sum { |v| v[:weight] || 0 }
449
+ return variants.first[:name] if total_weight.zero?
450
+
451
+ # Deterministic assignment: use MD5 hash of feature_name + user_id
452
+ # This ensures the same user always gets the same variant
453
+ user_id = context[:user_id] || context[:user]&.respond_to?(:id) && context[:user].id
454
+ if user_id
455
+ hash = Digest::MD5.hexdigest("#{name}:variant:#{user_id}")
456
+ bucket = hash[0..7].to_i(16) % total_weight
457
+ else
458
+ # No user context — fall back to random (e.g., anonymous requests)
459
+ bucket = rand(total_weight)
460
+ end
466
461
 
467
- # Rails 8+ event
468
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
469
- Magick::Rails::Events.variant_selected(name, variant_name: selected_variant, context: context)
462
+ current = 0
463
+ variants.each do |variant|
464
+ current += (variant[:weight] || 0)
465
+ return variant[:name] if bucket < current
470
466
  end
471
467
 
472
- selected_variant
468
+ variants.last[:name]
469
+ end
470
+
471
+ def get_variant_value(context = {})
472
+ variant_name = get_variant(context)
473
+ return nil unless variant_name
474
+
475
+ variant = targeting[:variants]&.find { |v| v[:name] == variant_name }
476
+ variant&.dig(:value)
473
477
  end
474
478
 
475
479
  def set_value(value, user_id: nil)
@@ -512,20 +516,16 @@ module Magick
512
516
  end
513
517
 
514
518
  def enable(user_id: nil)
515
- # Check if this feature is a dependency of any disabled features
516
- # If a main feature that depends on this feature is disabled, prevent enabling this dependency
517
- # Dependencies cannot be enabled until the main feature is enabled
518
- dependent_features = find_dependent_features
519
- disabled_dependents = dependent_features.select do |dep_feature_name|
520
- dep_feature = Magick.features[dep_feature_name.to_s] || Magick[dep_feature_name]
521
- # Check if the dependent feature (main feature) is disabled
522
- dep_feature && !dep_feature.enabled?
523
- end
519
+ # Check that all of this feature's own dependencies are enabled
520
+ # e.g. if checkout depends on payments, checkout can't be enabled until payments is
521
+ deps = @dependencies || []
522
+ unless deps.empty?
523
+ disabled_deps = deps.select do |dep_name|
524
+ dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
525
+ dep_feature && !dep_feature.enabled?
526
+ end
524
527
 
525
- unless disabled_dependents.empty?
526
- # Return false if any main feature that depends on this feature is disabled
527
- # This prevents enabling a dependency when the main feature is disabled
528
- return false
528
+ return false unless disabled_deps.empty?
529
529
  end
530
530
 
531
531
  # Clear all targeting to enable globally
@@ -984,13 +984,12 @@ module Magick
984
984
  end
985
985
 
986
986
  def disable_dependent_features(user_id: nil)
987
- # Cascade-disable this feature's own dependencies (downstream sub-features).
988
- # e.g. if A depends on B, disabling A also disables B.
989
- # But disabling B does NOT cascade up to A.
990
- deps = @dependencies || []
991
- return if deps.empty?
987
+ # Cascade-disable features that depend ON this feature.
988
+ # e.g. if checkout depends on payments, disabling payments also disables checkout.
989
+ dependents = find_dependent_features
990
+ return if dependents.empty?
992
991
 
993
- deps.each do |dep_name|
992
+ dependents.each do |dep_name|
994
993
  dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
995
994
  next unless dep_feature
996
995
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.2.3'
4
+ VERSION = '1.3.1'
5
5
  end
data/lib/magick.rb CHANGED
@@ -150,6 +150,16 @@ module Magick
150
150
  !enabled?(feature_name, context)
151
151
  end
152
152
 
153
+ def variant(feature_name, context = {})
154
+ feature = features[feature_name.to_s] || self[feature_name]
155
+ feature.get_variant(context)
156
+ end
157
+
158
+ def variant_value(feature_name, context = {})
159
+ feature = features[feature_name.to_s] || self[feature_name]
160
+ feature.get_variant_value(context)
161
+ end
162
+
153
163
  def exists?(feature_name)
154
164
  features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
155
165
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov