magick-feature-flags 0.9.37 → 1.0.0

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: 8fc6c9effed1052fbf1f929d206c9714917c1a5281234d517b4679cf9da44ded
4
- data.tar.gz: cb345d7d484c471906a91b47ef63c3450e7613015ac6ca335c24bd73390f95f3
3
+ metadata.gz: b702af543d2f50e7f5b7ba6917ed318cff4e0b1fd82ef355a8aac757a18be75d
4
+ data.tar.gz: 8d68c5bb592755731f46a681a76d4b9fa092ddd5b46300042c8225fa6fffb90b
5
5
  SHA512:
6
- metadata.gz: 3ad0164ef606ed0cf3112ee94013a084a45ae16a3fc9f8a3ed6647043be76aa367f868d38324dc7dcb5118e4263164902eedd7704846d90c90c993e09dba85d7
7
- data.tar.gz: f4b37a363842a2b34019c92370f0446a565a1087c90e0fb467244103bc1213c3dfc57b97c8b2e7d1b5623329862f9af23f9e75d2c0d0641ffb2b323d2289ba0a
6
+ metadata.gz: 21f49a8f8058dadddce614fa1e2766b385b6764ae7021e2a93ad6532edda4189d2a74bc6faeafb38f8dc1d4a980d9bc09c813836ee1e94f2381f2904f8b78bd6
7
+ data.tar.gz: 452eb3dbb5def24b61c89e5e1b08257a50b531df8834c2877fc1e926093a97c14c426a61be5cb3b61a41ab14d2a85bc061df9bd92229d8820f4452535dd1afe1
data/README.md CHANGED
@@ -5,7 +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
- - **Flexible Targeting**: Enable features for specific users, groups, roles, or percentages
8
+ - **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
9
9
  - **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
10
10
  - **Rails Integration**: Seamless integration with Rails, including request store caching
11
11
  - **DSL Support**: Define features in a Ruby DSL file (`config/features.rb`)
@@ -173,6 +173,10 @@ feature.enable_for_group("beta_testers")
173
173
  # Enable for specific role
174
174
  feature.enable_for_role("admin")
175
175
 
176
+ # Enable for specific tag
177
+ feature.enable_for_tag("premium")
178
+ feature.enable_for_tag("beta")
179
+
176
180
  # Enable for percentage of users (consistent)
177
181
  feature.enable_percentage_of_users(25) # 25% of users
178
182
 
@@ -215,12 +219,28 @@ end
215
219
  Magick.enabled_for?(:feature, user) # ActiveRecord object
216
220
  Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
217
221
  Magick.enabled_for?(:feature, 123) # Simple ID
222
+
223
+ # Tag targeting - tags are automatically extracted from user objects
224
+ user = User.find(123) # User has tags association
225
+ feature.enable_for_tag('premium')
226
+ Magick.enabled_for?(:feature, user) # Checks user.tags automatically
227
+
228
+ # Or explicitly pass tags
229
+ Magick.enabled?(:feature, tags: user.tags.map(&:id))
230
+ Magick.enabled?(:feature, tags: ['premium', 'beta'])
231
+
232
+ # Tags are extracted from:
233
+ # - user.tags (ActiveRecord association)
234
+ # - user.tag_ids (array of IDs)
235
+ # - user.tag_names (array of names)
236
+ # - hash[:tags], hash[:tag_ids], hash[:tag_names]
218
237
  ```
219
238
 
220
239
  The `enabled_for?` method automatically extracts:
221
240
  - `user_id` from `id` or `user_id` attribute
222
241
  - `group` from `group` attribute
223
242
  - `role` from `role` attribute
243
+ - `tags` from `tags` association, `tag_ids`, or `tag_names` methods/attributes
224
244
  - `ip_address` from `ip_address` attribute
225
245
  - All other attributes for custom attribute matching
226
246
 
@@ -234,6 +254,8 @@ result = feature.enable # => true
234
254
  result = feature.disable # => true
235
255
  result = feature.enable_for_user(123) # => true
236
256
  result = feature.enable_for_group('beta') # => true
257
+ result = feature.enable_for_role('admin') # => true
258
+ result = feature.enable_for_tag('premium') # => true
237
259
  result = feature.enable_percentage_of_users(25) # => true
238
260
  result = feature.set_value(true) # => true
239
261
  ```
@@ -520,12 +542,16 @@ Magick includes a web-based Admin UI for managing feature flags. It's a Rails En
520
542
 
521
543
  **Setup:**
522
544
 
523
- 1. Configure roles (optional) for targeting management in `config/initializers/magick.rb`:
545
+ 1. Configure roles and tags (optional) for targeting management in `config/initializers/magick.rb`:
524
546
 
525
547
  ```ruby
526
548
  Rails.application.config.after_initialize do
527
549
  Magick::AdminUI.configure do |config|
528
550
  config.available_roles = ['admin', 'user', 'manager', 'guest']
551
+ # Tags can be configured as an array or lambda (for dynamic loading)
552
+ config.available_tags = -> { Tag.all } # Lambda loads tags dynamically
553
+ # Or as a static array:
554
+ # config.available_tags = ['premium', 'beta', 'vip']
529
555
  end
530
556
  end
531
557
  ```
@@ -561,6 +587,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
561
587
  - **Enable/Disable**: Quickly enable or disable features globally
562
588
  - **Targeting Management**: Configure targeting rules through a user-friendly interface:
563
589
  - **Role Targeting**: Select roles from a configured list (checkboxes)
590
+ - **Tag Targeting**: Select tags from a dynamically loaded list (checkboxes)
564
591
  - **User Targeting**: Enter user IDs (comma-separated)
565
592
  - **Visual Display**: See all active targeting rules with badges
566
593
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
@@ -602,14 +629,21 @@ The Admin UI provides a comprehensive targeting interface:
602
629
  - Select multiple roles using checkboxes
603
630
  - Roles are automatically added/removed when checkboxes are toggled
604
631
 
605
- 2. **User Targeting**:
632
+ 2. **Tag Targeting**:
633
+ - Configure available tags via `Magick::AdminUI.configure` (supports lambda for dynamic loading)
634
+ - Tags are loaded dynamically each time the page loads (if using lambda)
635
+ - Select multiple tags using checkboxes
636
+ - Tags are automatically added/removed when checkboxes are toggled
637
+ - Tags can be ActiveRecord objects (IDs are stored) or simple strings
638
+
639
+ 3. **User Targeting**:
606
640
  - Enter user IDs as comma-separated values (e.g., `123, 456, 789`)
607
641
  - Add or remove users dynamically
608
642
  - Clear all user targeting by leaving the field empty
609
643
 
610
- 3. **Visual Feedback**:
644
+ 4. **Visual Feedback**:
611
645
  - All targeting rules are displayed as badges in the feature details view
612
- - Easy to see which roles/users have access to each feature
646
+ - Easy to see which roles/tags/users have access to each feature
613
647
 
614
648
  **Routes:**
615
649
 
@@ -9,7 +9,7 @@ module Magick
9
9
  before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting]
10
10
 
11
11
  # Make route helpers available in views via magick_admin_ui helper
12
- helper_method :magick_admin_ui, :available_roles, :partially_enabled?
12
+ helper_method :magick_admin_ui, :available_roles, :available_tags, :partially_enabled?
13
13
 
14
14
  def magick_admin_ui
15
15
  Magick::AdminUI::Engine.routes.url_helpers
@@ -19,6 +19,10 @@ module Magick
19
19
  Magick::AdminUI.config.available_roles || []
20
20
  end
21
21
 
22
+ def available_tags
23
+ Magick::AdminUI.config.tags
24
+ end
25
+
22
26
  def partially_enabled?(feature)
23
27
  targeting = feature.instance_variable_get(:@targeting) || {}
24
28
  targeting.any? && !targeting.empty?
@@ -134,6 +138,21 @@ module Magick
134
138
  @feature.enable_for_role(role) if role.present?
135
139
  end
136
140
 
141
+ # Handle tags - always clear existing and set new ones
142
+ # Rails checkboxes don't send unchecked values, so we need to check what was sent
143
+ current_tags = current_targeting[:tag].is_a?(Array) ? current_targeting[:tag] : (current_targeting[:tag] ? [current_targeting[:tag]] : [])
144
+ selected_tags = Array(targeting_params[:tags]).reject(&:blank?)
145
+
146
+ # Disable tags that are no longer selected
147
+ (current_tags - selected_tags).each do |tag|
148
+ @feature.disable_for_tag(tag) if tag.present?
149
+ end
150
+
151
+ # Enable newly selected tags
152
+ (selected_tags - current_tags).each do |tag|
153
+ @feature.enable_for_tag(tag) if tag.present?
154
+ end
155
+
137
156
  # Handle user IDs - replace existing user targeting
138
157
  if targeting_params[:user_ids].present?
139
158
  user_ids = targeting_params[:user_ids].split(',').map(&:strip).reject(&:blank?)
@@ -332,6 +332,100 @@
332
332
  box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1);
333
333
  }
334
334
 
335
+ /* Tags Search Interface */
336
+ .tags-search-container {
337
+ display: flex;
338
+ align-items: center;
339
+ gap: 12px;
340
+ margin-bottom: 12px;
341
+ }
342
+ .tags-search-input {
343
+ flex: 1;
344
+ padding: 10px 12px;
345
+ border: 1px solid #ddd;
346
+ border-radius: 4px;
347
+ font-size: 14px;
348
+ transition: border-color 0.2s, box-shadow 0.2s;
349
+ }
350
+ .tags-search-input:focus {
351
+ outline: none;
352
+ border-color: #3498db;
353
+ box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1);
354
+ }
355
+ .tags-count {
356
+ font-size: 13px;
357
+ color: #666;
358
+ white-space: nowrap;
359
+ }
360
+ .tags-checkbox-container {
361
+ max-height: 300px;
362
+ overflow-y: auto;
363
+ border: 1px solid #e0e0e0;
364
+ border-radius: 4px;
365
+ padding: 12px;
366
+ background: #fafafa;
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 8px;
370
+ }
371
+ .tags-checkbox-container::-webkit-scrollbar {
372
+ width: 8px;
373
+ }
374
+ .tags-checkbox-container::-webkit-scrollbar-track {
375
+ background: #f1f1f1;
376
+ border-radius: 4px;
377
+ }
378
+ .tags-checkbox-container::-webkit-scrollbar-thumb {
379
+ background: #ccc;
380
+ border-radius: 4px;
381
+ }
382
+ .tags-checkbox-container::-webkit-scrollbar-thumb:hover {
383
+ background: #999;
384
+ }
385
+ .tag-checkbox-label {
386
+ display: inline-flex;
387
+ align-items: center;
388
+ cursor: pointer;
389
+ padding: 6px 8px;
390
+ border-radius: 4px;
391
+ transition: background-color 0.2s;
392
+ }
393
+ .tag-checkbox-label:hover {
394
+ background-color: #f0f0f0;
395
+ }
396
+ .tag-checkbox-label.hidden {
397
+ display: none;
398
+ }
399
+ .tag-checkbox {
400
+ margin-right: 8px;
401
+ cursor: pointer;
402
+ }
403
+ .tag-checkbox-text {
404
+ font-size: 14px;
405
+ color: #333;
406
+ user-select: none;
407
+ }
408
+ .tag-checkbox:checked + .tag-checkbox-text {
409
+ font-weight: 500;
410
+ color: #1976d2;
411
+ }
412
+ .tag-checkbox:checked ~ .tag-checkbox-text {
413
+ font-weight: 500;
414
+ color: #1976d2;
415
+ }
416
+ /* Highlight checked labels */
417
+ .tag-checkbox-label:has(.tag-checkbox:checked) {
418
+ background-color: #e3f2fd;
419
+ }
420
+ /* Fallback for browsers without :has() support */
421
+ .tag-checkbox-label.checked {
422
+ background-color: #e3f2fd;
423
+ }
424
+ .tag-checkbox-label.checked .tag-checkbox-text {
425
+ font-weight: 500;
426
+ color: #1976d2;
427
+ }
428
+
335
429
  /* Mobile Responsive */
336
430
  @media (max-width: 768px) {
337
431
  .container {
@@ -419,6 +513,17 @@
419
513
  min-width: 100px;
420
514
  font-size: 16px; /* Prevents zoom on iOS */
421
515
  }
516
+ .tags-search-container {
517
+ flex-direction: column;
518
+ align-items: stretch;
519
+ }
520
+ .tags-count {
521
+ text-align: right;
522
+ margin-top: 4px;
523
+ }
524
+ .tags-checkbox-container {
525
+ max-height: 200px;
526
+ }
422
527
  }
423
528
 
424
529
  /* Tablet */
@@ -528,6 +633,176 @@
528
633
  });
529
634
  });
530
635
  })();
636
+
637
+ // Tags Search Filter
638
+ (function() {
639
+ const searchInput = document.getElementById('tags_search');
640
+ const checkboxContainer = document.getElementById('tags_checkbox_container');
641
+ const visibleCount = document.getElementById('tags_visible_count');
642
+ const totalCount = document.getElementById('tags_total_count');
643
+
644
+ if (!searchInput || !checkboxContainer || !visibleCount || !totalCount) return;
645
+
646
+ function filterTags() {
647
+ const searchTerm = searchInput.value.toLowerCase().trim();
648
+ const labels = Array.from(checkboxContainer.querySelectorAll('.tag-checkbox-label'));
649
+ let visibleLabels = [];
650
+ let hiddenLabels = [];
651
+
652
+ labels.forEach(label => {
653
+ const tagName = label.getAttribute('data-tag-name') || '';
654
+ const tagId = label.getAttribute('data-tag-id') || '';
655
+ const tagText = label.querySelector('.tag-checkbox-text')?.textContent.toLowerCase() || '';
656
+ const checkbox = label.querySelector('.tag-checkbox');
657
+ const isChecked = checkbox && checkbox.checked;
658
+
659
+ // Match against tag name, id, or display text
660
+ const matches = !searchTerm ||
661
+ tagName.includes(searchTerm) ||
662
+ tagId.includes(searchTerm) ||
663
+ tagText.includes(searchTerm);
664
+
665
+ if (matches) {
666
+ label.classList.remove('hidden');
667
+ visibleLabels.push({ label: label, checked: isChecked });
668
+ } else {
669
+ label.classList.add('hidden');
670
+ hiddenLabels.push(label);
671
+ }
672
+ });
673
+
674
+ // Sort visible labels: checked first, then unchecked
675
+ visibleLabels.sort((a, b) => {
676
+ if (a.checked && !b.checked) return -1;
677
+ if (!a.checked && b.checked) return 1;
678
+ return 0;
679
+ });
680
+
681
+ // Reorder DOM: checked items first, then unchecked, then hidden
682
+ visibleLabels.forEach(({ label }) => {
683
+ checkboxContainer.appendChild(label);
684
+ });
685
+ hiddenLabels.forEach(label => {
686
+ checkboxContainer.appendChild(label);
687
+ });
688
+
689
+ visibleCount.textContent = visibleLabels.length;
690
+ }
691
+
692
+ // Filter on input
693
+ searchInput.addEventListener('input', filterTags);
694
+
695
+ // Clear search on Escape key
696
+ searchInput.addEventListener('keydown', function(e) {
697
+ if (e.key === 'Escape') {
698
+ searchInput.value = '';
699
+ filterTags();
700
+ searchInput.blur();
701
+ }
702
+ });
703
+
704
+ // Update checked state classes and reorder on change
705
+ checkboxContainer.querySelectorAll('.tag-checkbox').forEach(checkbox => {
706
+ function updateCheckedClass() {
707
+ const label = checkbox.closest('.tag-checkbox-label');
708
+ if (checkbox.checked) {
709
+ label.classList.add('checked');
710
+ label.setAttribute('data-checked', 'true');
711
+ } else {
712
+ label.classList.remove('checked');
713
+ label.setAttribute('data-checked', 'false');
714
+ }
715
+ // Reorder after change to keep checked items on top
716
+ filterTags();
717
+ }
718
+ checkbox.addEventListener('change', updateCheckedClass);
719
+ updateCheckedClass(); // Initial state
720
+ });
721
+ })();
722
+
723
+ // Roles Search Filter
724
+ (function() {
725
+ const searchInput = document.getElementById('roles_search');
726
+ const checkboxContainer = document.getElementById('roles_checkbox_container');
727
+ const visibleCount = document.getElementById('roles_visible_count');
728
+ const totalCount = document.getElementById('roles_total_count');
729
+
730
+ if (!searchInput || !checkboxContainer || !visibleCount || !totalCount) return;
731
+
732
+ function filterRoles() {
733
+ const searchTerm = searchInput.value.toLowerCase().trim();
734
+ const labels = Array.from(checkboxContainer.querySelectorAll('.tag-checkbox-label'));
735
+ let visibleLabels = [];
736
+ let hiddenLabels = [];
737
+
738
+ labels.forEach(label => {
739
+ const roleName = label.getAttribute('data-role-name') || '';
740
+ const roleText = label.querySelector('.tag-checkbox-text')?.textContent.toLowerCase() || '';
741
+ const checkbox = label.querySelector('.tag-checkbox');
742
+ const isChecked = checkbox && checkbox.checked;
743
+
744
+ // Match against role name or display text
745
+ const matches = !searchTerm ||
746
+ roleName.includes(searchTerm) ||
747
+ roleText.includes(searchTerm);
748
+
749
+ if (matches) {
750
+ label.classList.remove('hidden');
751
+ visibleLabels.push({ label: label, checked: isChecked });
752
+ } else {
753
+ label.classList.add('hidden');
754
+ hiddenLabels.push(label);
755
+ }
756
+ });
757
+
758
+ // Sort visible labels: checked first, then unchecked
759
+ visibleLabels.sort((a, b) => {
760
+ if (a.checked && !b.checked) return -1;
761
+ if (!a.checked && b.checked) return 1;
762
+ return 0;
763
+ });
764
+
765
+ // Reorder DOM: checked items first, then unchecked, then hidden
766
+ visibleLabels.forEach(({ label }) => {
767
+ checkboxContainer.appendChild(label);
768
+ });
769
+ hiddenLabels.forEach(label => {
770
+ checkboxContainer.appendChild(label);
771
+ });
772
+
773
+ visibleCount.textContent = visibleLabels.length;
774
+ }
775
+
776
+ // Filter on input
777
+ searchInput.addEventListener('input', filterRoles);
778
+
779
+ // Clear search on Escape key
780
+ searchInput.addEventListener('keydown', function(e) {
781
+ if (e.key === 'Escape') {
782
+ searchInput.value = '';
783
+ filterRoles();
784
+ searchInput.blur();
785
+ }
786
+ });
787
+
788
+ // Update checked state classes and reorder on change
789
+ checkboxContainer.querySelectorAll('.tag-checkbox').forEach(checkbox => {
790
+ function updateCheckedClass() {
791
+ const label = checkbox.closest('.tag-checkbox-label');
792
+ if (checkbox.checked) {
793
+ label.classList.add('checked');
794
+ label.setAttribute('data-checked', 'true');
795
+ } else {
796
+ label.classList.remove('checked');
797
+ label.setAttribute('data-checked', 'false');
798
+ }
799
+ // Reorder after change to keep checked items on top
800
+ filterRoles();
801
+ }
802
+ checkbox.addEventListener('change', updateCheckedClass);
803
+ updateCheckedClass(); // Initial state
804
+ });
805
+ })();
531
806
  </script>
532
807
  </body>
533
808
  </html>
@@ -70,11 +70,22 @@
70
70
  <% if roles_list.any? %>
71
71
  <div class="form-group">
72
72
  <label>Enable for Roles</label>
73
- <div style="display: flex; flex-direction: column; gap: 8px;">
74
- <% roles_list.each do |role| %>
75
- <label style="display: inline-flex; align-items: center; cursor: pointer;">
76
- <%= check_box_tag 'targeting[roles][]', role, current_roles.include?(role.to_s), id: "role_#{role}" %>
77
- <span style="margin-left: 8px;"><%= role.to_s.humanize %></span>
73
+ <div class="tags-search-container">
74
+ <input type="text" id="roles_search" class="tags-search-input" placeholder="Search roles... (e.g., admin, manager)" autocomplete="off">
75
+ <div class="tags-count" id="roles_count">
76
+ <span id="roles_visible_count"><%= roles_list.length %></span> of <span id="roles_total_count"><%= roles_list.length %></span> roles
77
+ </div>
78
+ </div>
79
+ <div class="tags-checkbox-container" id="roles_checkbox_container">
80
+ <%# Render checked roles first, then unchecked %>
81
+ <% checked_roles, unchecked_roles = roles_list.partition { |role| current_roles.include?(role.to_s) } %>
82
+ <% (checked_roles + unchecked_roles).each do |role| %>
83
+ <% role_name = role.to_s %>
84
+ <% role_display = role.to_s.humanize %>
85
+ <% is_checked = current_roles.include?(role.to_s) %>
86
+ <label class="tag-checkbox-label <%= 'checked' if is_checked %>" data-role-name="<%= role_name.downcase %>" data-checked="<%= is_checked %>">
87
+ <%= check_box_tag 'targeting[roles][]', role, is_checked, id: "role_#{role}", class: 'tag-checkbox' %>
88
+ <span class="tag-checkbox-text"><%= role_display %></span>
78
89
  </label>
79
90
  <% end %>
80
91
  </div>
@@ -87,6 +98,42 @@
87
98
  </div>
88
99
  <% end %>
89
100
 
101
+ <% current_tags = targeting[:tag].is_a?(Array) ? targeting[:tag] : (targeting[:tag] ? [targeting[:tag]] : []) %>
102
+ <% tags_list = available_tags %>
103
+ <% if tags_list.any? %>
104
+ <div class="form-group">
105
+ <label>Enable for Tags</label>
106
+ <div class="tags-search-container">
107
+ <input type="text" id="tags_search" class="tags-search-input" placeholder="Search tags... (e.g., test, MONITOR)" autocomplete="off">
108
+ <div class="tags-count" id="tags_count">
109
+ <span id="tags_visible_count"><%= tags_list.length %></span> of <span id="tags_total_count"><%= tags_list.length %></span> tags
110
+ </div>
111
+ </div>
112
+ <div class="tags-checkbox-container" id="tags_checkbox_container">
113
+ <%# Render checked tags first, then unchecked %>
114
+ <% checked_tags, unchecked_tags = tags_list.partition do |tag|
115
+ tag_id = tag.respond_to?(:id) ? tag.id.to_s : tag.to_s
116
+ current_tags.include?(tag_id.to_s)
117
+ end %>
118
+ <% (checked_tags + unchecked_tags).each do |tag| %>
119
+ <% tag_id = tag.respond_to?(:id) ? tag.id.to_s : tag.to_s %>
120
+ <% tag_name = tag.respond_to?(:name) ? tag.name : (tag.respond_to?(:to_s) ? tag.to_s : tag_id) %>
121
+ <% is_checked = current_tags.include?(tag_id.to_s) %>
122
+ <label class="tag-checkbox-label <%= 'checked' if is_checked %>" data-tag-name="<%= tag_name.downcase %>" data-tag-id="<%= tag_id.downcase %>" data-checked="<%= is_checked %>">
123
+ <%= check_box_tag 'targeting[tags][]', tag_id, is_checked, id: "tag_#{tag_id}", class: 'tag-checkbox' %>
124
+ <span class="tag-checkbox-text"><%= tag_name %></span>
125
+ </label>
126
+ <% end %>
127
+ </div>
128
+ <small style="color: #999;">Select tags that should have access to this feature. Tags are loaded dynamically from your configuration.</small>
129
+ </div>
130
+ <% else %>
131
+ <div class="alert alert-info">
132
+ <p>No tags configured. Add tags via DSL:</p>
133
+ <code>Magick::AdminUI.configure { |config| config.available_tags = -> { Tag.all } }</code>
134
+ </div>
135
+ <% end %>
136
+
90
137
  <div class="form-group">
91
138
  <label>Enable for User IDs</label>
92
139
  <div class="user-ids-input-container">
@@ -17,13 +17,21 @@ module Magick
17
17
  end
18
18
 
19
19
  class Configuration
20
- attr_accessor :theme, :brand_name, :require_role, :available_roles
20
+ attr_accessor :theme, :brand_name, :require_role, :available_roles, :available_tags
21
21
 
22
22
  def initialize
23
23
  @theme = :light
24
24
  @brand_name = 'Magick'
25
25
  @require_role = nil
26
26
  @available_roles = [] # Can be populated via DSL: admin_ui { roles ['admin', 'user', 'manager'] }
27
+ @available_tags = nil # Can be array or lambda: -> { Tag.all }
28
+ end
29
+
30
+ # Get available tags, calling lambda if needed
31
+ def tags
32
+ return [] if @available_tags.nil?
33
+ return @available_tags.call if @available_tags.respond_to?(:call)
34
+ Array(@available_tags)
27
35
  end
28
36
  end
29
37
  end
data/lib/magick/dsl.rb CHANGED
@@ -32,6 +32,10 @@ module Magick
32
32
  Magick[feature_name].enable_for_role(role_name)
33
33
  end
34
34
 
35
+ def enable_for_tag(feature_name, tag_name)
36
+ Magick[feature_name].enable_for_tag(tag_name)
37
+ end
38
+
35
39
  def enable_percentage(feature_name, percentage, type: :users)
36
40
  feature = Magick[feature_name]
37
41
  case type
@@ -89,6 +89,18 @@ module Magick
89
89
  end
90
90
 
91
91
  def check_enabled(context = {})
92
+ # Extract context from user object if provided
93
+ # This allows Magick.enabled?(:feature, user: player) to work
94
+ if context[:user]
95
+ extracted = extract_context_from_object(context[:user])
96
+ # Merge extracted context, but don't override explicit values already in context
97
+ extracted.each do |key, value|
98
+ context[key] = value unless context.key?(key)
99
+ end
100
+ # Remove :user key after extraction to avoid confusion
101
+ context.delete(:user)
102
+ end
103
+
92
104
  # Fast path: check status first
93
105
  return false if status == :inactive
94
106
  return false if status == :deprecated && !context[:allow_deprecated]
@@ -111,17 +123,14 @@ module Magick
111
123
 
112
124
  # Check user/group/role/percentage targeting
113
125
  targeting_result = check_targeting(context)
114
- if targeting_result.nil?
115
- # Targeting doesn't match - return false
116
- return false
117
- else
118
- # Targeting matches - for boolean features, return true directly
119
- # For string/number features, still check the value
120
- if type == :boolean
121
- return true
122
- end
123
- # For string/number, continue to check value below
124
- end
126
+ return false if targeting_result.nil?
127
+ # Targeting doesn't match - return false
128
+
129
+ # Targeting matches - for boolean features, return true directly
130
+ # For string/number features, still check the value
131
+ return true if type == :boolean
132
+ # For string/number, continue to check value below
133
+
125
134
  end
126
135
 
127
136
  # Get value and check based on type
@@ -170,23 +179,22 @@ module Magick
170
179
  # If targeting doesn't match (returns nil), continue to return default value
171
180
  unless targeting_result.nil?
172
181
  # Targeting matches - return stored value (or load it if not initialized)
173
- if @stored_value_initialized
174
- return @stored_value
182
+ return @stored_value if @stored_value_initialized
183
+
184
+ # Load from adapter
185
+ loaded_value = load_value_from_adapter
186
+ if loaded_value.nil?
187
+ # Value not found in adapter, use default and cache it
188
+ @stored_value = default_value
189
+ @stored_value_initialized = true
190
+ return default_value
175
191
  else
176
- # Load from adapter
177
- loaded_value = load_value_from_adapter
178
- if loaded_value.nil?
179
- # Value not found in adapter, use default and cache it
180
- @stored_value = default_value
181
- @stored_value_initialized = true
182
- return default_value
183
- else
184
- # Value found in adapter, use it and mark as initialized
185
- @stored_value = loaded_value
186
- @stored_value_initialized = true
187
- return loaded_value
188
- end
192
+ # Value found in adapter, use it and mark as initialized
193
+ @stored_value = loaded_value
194
+ @stored_value_initialized = true
195
+ return loaded_value
189
196
  end
197
+
190
198
  end
191
199
  # Targeting doesn't match - return default value
192
200
  return default_value
@@ -244,6 +252,16 @@ module Magick
244
252
  true
245
253
  end
246
254
 
255
+ def enable_for_tag(tag_name)
256
+ enable_targeting(:tag, tag_name)
257
+ true
258
+ end
259
+
260
+ def disable_for_tag(tag_name)
261
+ disable_targeting(:tag, tag_name)
262
+ true
263
+ end
264
+
247
265
  def enable_percentage_of_users(percentage)
248
266
  @targeting[:percentage_users] = percentage.to_f
249
267
  save_targeting
@@ -529,9 +547,7 @@ module Magick
529
547
  end
530
548
 
531
549
  # Update registered feature instance if it exists
532
- if Magick.features.key?(name)
533
- Magick.features[name].instance_variable_set(:@group, @group)
534
- end
550
+ Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
535
551
 
536
552
  true
537
553
  end
@@ -597,7 +613,7 @@ module Magick
597
613
  # Update local targeting empty cache for performance
598
614
  @_targeting_empty = targeting.empty?
599
615
 
600
- # Note: We don't need to explicitly publish cache invalidation here because:
616
+ # NOTE: We don't need to explicitly publish cache invalidation here because:
601
617
  # 1. adapter_registry.set already publishes cache invalidation (synchronously for async Redis updates)
602
618
  # 2. Publishing twice causes duplicate reloads in other processes
603
619
  # 3. The set method handles both sync and async Redis updates correctly
@@ -705,6 +721,14 @@ module Magick
705
721
  return true if role_list.include?(context[:role].to_s)
706
722
  end
707
723
 
724
+ # Check tag targeting
725
+ if context[:tags] && target[:tag]
726
+ context_tags = Array(context[:tags]).map(&:to_s)
727
+ target_tags = target[:tag].is_a?(Array) ? target[:tag].map(&:to_s) : [target[:tag].to_s]
728
+ # Return true if any context tag matches any target tag
729
+ return true if (context_tags & target_tags).any?
730
+ end
731
+
708
732
  # Check percentage of users (consistent based on user_id)
709
733
  if context[:user_id] && target[:percentage_users]
710
734
  percentage = target[:percentage_users].to_f
@@ -830,10 +854,13 @@ module Magick
830
854
  context[:group] = object[:group] || object['group']
831
855
  context[:role] = object[:role] || object['role']
832
856
  context[:ip_address] = object[:ip_address] || object['ip_address']
857
+ # Extract tags from hash
858
+ tags = object[:tags] || object['tags'] || object[:tag_ids] || object['tag_ids'] || object[:tag_names] || object['tag_names']
859
+ context[:tags] = Array(tags).map(&:to_s) if tags
833
860
  # Include all other attributes for custom attribute matching
834
861
  object.each do |key, value|
835
- next if %i[user_id id group role ip_address].include?(key.to_sym)
836
- next if %w[user_id id group role ip_address].include?(key.to_s)
862
+ next if %i[user_id id group role ip_address tags tag_ids tag_names].include?(key.to_sym)
863
+ next if %w[user_id id group role ip_address tags tag_ids tag_names].include?(key.to_s)
837
864
 
838
865
  context[key.to_sym] = value
839
866
  end
@@ -844,10 +871,32 @@ module Magick
844
871
  context[:role] = object.role if object.respond_to?(:role)
845
872
  context[:ip_address] = object.ip_address if object.respond_to?(:ip_address)
846
873
 
874
+ # Extract tags from object - try multiple common patterns
875
+ tags = nil
876
+ if object.respond_to?(:tags)
877
+ tags = object.tags
878
+ # Handle ActiveRecord associations - convert to array if needed
879
+ tags = tags.to_a if tags.respond_to?(:to_a) && !tags.is_a?(Array)
880
+ elsif object.respond_to?(:tag_ids)
881
+ tags = object.tag_ids
882
+ elsif object.respond_to?(:tag_names)
883
+ tags = object.tag_names
884
+ end
885
+
886
+ # Normalize tags to array of strings
887
+ if tags
888
+ context[:tags] = if tags.respond_to?(:map) && tags.respond_to?(:each)
889
+ # ActiveRecord association or array
890
+ tags.map { |tag| tag.respond_to?(:id) ? tag.id.to_s : tag.to_s }
891
+ else
892
+ Array(tags).map(&:to_s)
893
+ end
894
+ end
895
+
847
896
  # For ActiveRecord objects, include all attributes
848
897
  if object.respond_to?(:attributes)
849
898
  object.attributes.each do |key, value|
850
- next if %w[id user_id group role ip_address].include?(key.to_s)
899
+ next if %w[id user_id group role ip_address tags tag_ids tag_names].include?(key.to_s)
851
900
 
852
901
  context[key.to_sym] = value
853
902
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.9.37'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/magick.rb CHANGED
@@ -90,7 +90,7 @@ module Magick
90
90
 
91
91
  if block.arity.zero?
92
92
  # New DSL style - calls apply! automatically
93
- ConfigDSL.configure(&block)
93
+ Magick::ConfigDSL.configure(&block)
94
94
  else
95
95
  # Old style - need to manually reapply Redis tracking after configuration
96
96
  yield self
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: 0.9.37
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov