magick-feature-flags 0.9.35 → 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: 5a2d57b267028e1f3d30a1a5dc4e540d6b254d41ea1b436f5ab0e25278c0f213
4
- data.tar.gz: 0c1a022445c14e1534b312f4cfc71ec71b9a576d4b66a5d860323a480df79380
3
+ metadata.gz: b702af543d2f50e7f5b7ba6917ed318cff4e0b1fd82ef355a8aac757a18be75d
4
+ data.tar.gz: 8d68c5bb592755731f46a681a76d4b9fa092ddd5b46300042c8225fa6fffb90b
5
5
  SHA512:
6
- metadata.gz: a0576cc8537ef6df5ff4ce95f973994a477936d94cde874b24945505139fbf411798fb54d100544f1848ad1b2388164b64a95421732daa33d753bcef7c14cce5
7
- data.tar.gz: 803584894d4c2494c324df94613a80a34ca268f522ffabe36bc162e989ca25be8aaa9d1f919f10584e722b47863940a1610c6f7cbc75d6960b2070304e4efe55
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`)
@@ -45,18 +45,15 @@ This will create `config/initializers/magick.rb` with a basic configuration.
45
45
 
46
46
  ### ActiveRecord Adapter (Optional)
47
47
 
48
- If you want to use ActiveRecord as a persistent storage backend, generate the migration:
48
+ If you want to use ActiveRecord as a persistent storage backend, you **must** generate and run the migration:
49
49
 
50
50
  ```bash
51
51
  rails generate magick:active_record
52
- ```
53
-
54
- This will create a migration file that creates the `magick_features` table. Then run:
55
-
56
- ```bash
57
52
  rails db:migrate
58
53
  ```
59
54
 
55
+ This will create a migration file that creates the `magick_features` table. **The adapter will not auto-create the table** - you must run migrations.
56
+
60
57
  **Note:** The ActiveRecord adapter is optional and only needed if you want database-backed feature flags. The gem works perfectly fine with just the memory adapter or Redis adapter.
61
58
 
62
59
  ## Configuration
@@ -137,7 +134,8 @@ end
137
134
  Magick.register_feature(:new_dashboard,
138
135
  type: :boolean,
139
136
  default_value: false,
140
- description: "New dashboard UI"
137
+ description: "New dashboard UI",
138
+ group: "UI" # Optional: group features for organization
141
139
  )
142
140
 
143
141
  # Register a string feature
@@ -175,6 +173,10 @@ feature.enable_for_group("beta_testers")
175
173
  # Enable for specific role
176
174
  feature.enable_for_role("admin")
177
175
 
176
+ # Enable for specific tag
177
+ feature.enable_for_tag("premium")
178
+ feature.enable_for_tag("beta")
179
+
178
180
  # Enable for percentage of users (consistent)
179
181
  feature.enable_percentage_of_users(25) # 25% of users
180
182
 
@@ -217,12 +219,28 @@ end
217
219
  Magick.enabled_for?(:feature, user) # ActiveRecord object
218
220
  Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
219
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]
220
237
  ```
221
238
 
222
239
  The `enabled_for?` method automatically extracts:
223
240
  - `user_id` from `id` or `user_id` attribute
224
241
  - `group` from `group` attribute
225
242
  - `role` from `role` attribute
243
+ - `tags` from `tags` association, `tag_ids`, or `tag_names` methods/attributes
226
244
  - `ip_address` from `ip_address` attribute
227
245
  - All other attributes for custom attribute matching
228
246
 
@@ -236,6 +254,8 @@ result = feature.enable # => true
236
254
  result = feature.disable # => true
237
255
  result = feature.enable_for_user(123) # => true
238
256
  result = feature.enable_for_group('beta') # => true
257
+ result = feature.enable_for_role('admin') # => true
258
+ result = feature.enable_for_tag('premium') # => true
239
259
  result = feature.enable_percentage_of_users(25) # => true
240
260
  result = feature.set_value(true) # => true
241
261
  ```
@@ -460,7 +480,7 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
460
480
 
461
481
  **Setup:**
462
482
 
463
- 1. Generate the migration:
483
+ 1. **Generate and run the migration** (required):
464
484
  ```bash
465
485
  rails generate magick:active_record
466
486
  rails db:migrate
@@ -469,8 +489,11 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
469
489
  **With UUID primary keys:**
470
490
  ```bash
471
491
  rails generate magick:active_record --uuid
492
+ rails db:migrate
472
493
  ```
473
494
 
495
+ **Important:** The adapter will **not** auto-create the table. You must run migrations before using the ActiveRecord adapter. If the table doesn't exist, the adapter will raise a clear error with instructions.
496
+
474
497
  2. Configure in `config/initializers/magick.rb`:
475
498
  ```ruby
476
499
  Magick.configure do
@@ -480,8 +503,6 @@ The ActiveRecord adapter provides database-backed persistent storage for feature
480
503
  end
481
504
  ```
482
505
 
483
- The adapter automatically creates the `magick_features` table if it doesn't exist, but using the generator is recommended for production applications.
484
-
485
506
  **PostgreSQL Support:**
486
507
 
487
508
  The generator automatically detects PostgreSQL and uses `jsonb` for the `data` column, providing:
@@ -521,12 +542,16 @@ Magick includes a web-based Admin UI for managing feature flags. It's a Rails En
521
542
 
522
543
  **Setup:**
523
544
 
524
- 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`:
525
546
 
526
547
  ```ruby
527
548
  Rails.application.config.after_initialize do
528
549
  Magick::AdminUI.configure do |config|
529
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']
530
555
  end
531
556
  end
532
557
  ```
@@ -562,10 +587,38 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
562
587
  - **Enable/Disable**: Quickly enable or disable features globally
563
588
  - **Targeting Management**: Configure targeting rules through a user-friendly interface:
564
589
  - **Role Targeting**: Select roles from a configured list (checkboxes)
590
+ - **Tag Targeting**: Select tags from a dynamically loaded list (checkboxes)
565
591
  - **User Targeting**: Enter user IDs (comma-separated)
566
592
  - **Visual Display**: See all active targeting rules with badges
567
593
  - **Edit Features**: Update feature values (boolean, string, number) directly from the UI
568
594
  - **Statistics**: View performance metrics and usage statistics for each feature
595
+ - **Feature Grouping**: Organize features into groups for easier management and filtering
596
+ - **Filtering**: Filter features by group, name, or description
597
+
598
+ **Feature Grouping:**
599
+
600
+ Features can be organized into groups for easier management and filtering:
601
+
602
+ 1. **Setting Groups**:
603
+ - Set a group when registering a feature in code:
604
+ ```ruby
605
+ Magick.register_feature(:new_payment_flow,
606
+ type: :boolean,
607
+ default_value: false,
608
+ group: 'Payment',
609
+ description: "New payment processing flow"
610
+ )
611
+ ```
612
+ - Or set/update groups via the Admin UI when editing a feature
613
+
614
+ 2. **Filtering by Group**:
615
+ - Use the group dropdown in the Admin UI to filter features by group
616
+ - Combine group filtering with search to find specific features quickly
617
+
618
+ 3. **Benefits**:
619
+ - Organize features by functional area (e.g., "Authentication", "Payment", "UI")
620
+ - Quickly find related features
621
+ - Better organization for large feature flag sets
569
622
 
570
623
  **Targeting Management:**
571
624
 
@@ -576,14 +629,21 @@ The Admin UI provides a comprehensive targeting interface:
576
629
  - Select multiple roles using checkboxes
577
630
  - Roles are automatically added/removed when checkboxes are toggled
578
631
 
579
- 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**:
580
640
  - Enter user IDs as comma-separated values (e.g., `123, 456, 789`)
581
641
  - Add or remove users dynamically
582
642
  - Clear all user targeting by leaving the field empty
583
643
 
584
- 3. **Visual Feedback**:
644
+ 4. **Visual Feedback**:
585
645
  - All targeting rules are displayed as badges in the feature details view
586
- - Easy to see which roles/users have access to each feature
646
+ - Easy to see which roles/tags/users have access to each feature
587
647
 
588
648
  **Routes:**
589
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?
@@ -26,6 +30,24 @@ module Magick
26
30
 
27
31
  def index
28
32
  @features = Magick.features.values
33
+
34
+ # Filter by group if provided
35
+ if params[:group].present?
36
+ @features = @features.select { |f| f.group == params[:group] }
37
+ end
38
+
39
+ # Filter by search query (name or description)
40
+ if params[:search].present?
41
+ search_term = params[:search].downcase
42
+ @features = @features.select do |f|
43
+ f.name.downcase.include?(search_term) ||
44
+ (f.display_name && f.display_name.downcase.include?(search_term)) ||
45
+ (f.description && f.description.downcase.include?(search_term))
46
+ end
47
+ end
48
+
49
+ # Get all available groups for filter dropdown
50
+ @available_groups = Magick.features.values.map(&:group).compact.uniq.sort
29
51
  end
30
52
 
31
53
  def show
@@ -35,6 +57,11 @@ module Magick
35
57
  end
36
58
 
37
59
  def update
60
+ # Update group if provided
61
+ if params.key?(:group)
62
+ @feature.set_group(params[:group])
63
+ end
64
+
38
65
  if @feature.type == :boolean
39
66
  # For boolean features, checkbox sends 'true' when checked, nothing when unchecked
40
67
  # Rails form helpers handle this - if checkbox is unchecked, params[:value] will be nil
@@ -111,6 +138,21 @@ module Magick
111
138
  @feature.enable_for_role(role) if role.present?
112
139
  end
113
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
+
114
156
  # Handle user IDs - replace existing user targeting
115
157
  if targeting_params[:user_ids].present?
116
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>
@@ -18,6 +18,12 @@
18
18
  <input type="text" value="<%= @feature.type.to_s.capitalize %>" disabled>
19
19
  </div>
20
20
 
21
+ <div class="form-group">
22
+ <label>Group</label>
23
+ <%= text_field_tag 'group', @feature.group, placeholder: 'e.g., Authentication, Payment, UI', class: 'form-control' %>
24
+ <small style="color: #999;">Optional: Group features together for easier organization and filtering</small>
25
+ </div>
26
+
21
27
  <div class="form-group">
22
28
  <label>Current Value</label>
23
29
  <% if @feature.type == :boolean %>
@@ -64,11 +70,22 @@
64
70
  <% if roles_list.any? %>
65
71
  <div class="form-group">
66
72
  <label>Enable for Roles</label>
67
- <div style="display: flex; flex-direction: column; gap: 8px;">
68
- <% roles_list.each do |role| %>
69
- <label style="display: inline-flex; align-items: center; cursor: pointer;">
70
- <%= check_box_tag 'targeting[roles][]', role, current_roles.include?(role.to_s), id: "role_#{role}" %>
71
- <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>
72
89
  </label>
73
90
  <% end %>
74
91
  </div>
@@ -81,6 +98,42 @@
81
98
  </div>
82
99
  <% end %>
83
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
+
84
137
  <div class="form-group">
85
138
  <label>Enable for User IDs</label>
86
139
  <div class="user-ids-input-container">
@@ -6,6 +6,26 @@
6
6
  </div>
7
7
  </div>
8
8
 
9
+ <!-- Filtering UI -->
10
+ <div style="padding: 16px; border-bottom: 1px solid #e0e0e0; background: #f9f9f9;">
11
+ <%= form_with url: magick_admin_ui.features_path, method: :get, local: true, style: "display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;" do |f| %>
12
+ <div style="flex: 1; min-width: 200px;">
13
+ <label for="search" style="display: block; margin-bottom: 4px; font-weight: 500;">Search</label>
14
+ <%= text_field_tag :search, params[:search], placeholder: "Search by name or description...", class: 'form-control', style: "width: 100%;" %>
15
+ </div>
16
+ <div style="flex: 0 0 180px;">
17
+ <label for="group" style="display: block; margin-bottom: 4px; font-weight: 500;">Group</label>
18
+ <%= select_tag :group, options_for_select([['All Groups', '']] + @available_groups.map { |g| [g, g] }, params[:group]), class: 'form-control', style: "width: 100%;" %>
19
+ </div>
20
+ <div style="flex: 0 0 auto;">
21
+ <%= submit_tag 'Filter', class: 'btn btn-primary btn-sm', style: "height: 38px;" %>
22
+ <% if params[:search].present? || params[:group].present? %>
23
+ <%= link_to 'Clear', magick_admin_ui.features_path, class: 'btn btn-secondary btn-sm', style: "height: 38px;" %>
24
+ <% end %>
25
+ </div>
26
+ <% end %>
27
+ </div>
28
+
9
29
  <% if @features.empty? %>
10
30
  <div class="empty-state">
11
31
  <h3>No features found</h3>
@@ -16,6 +36,7 @@
16
36
  <thead>
17
37
  <tr>
18
38
  <th>Name</th>
39
+ <th>Group</th>
19
40
  <th>Type</th>
20
41
  <th>Status</th>
21
42
  <th>Value</th>
@@ -31,6 +52,13 @@
31
52
  <br>
32
53
  <small style="color: #999;"><%= feature.name %></small>
33
54
  </td>
55
+ <td data-label="Group">
56
+ <% if feature.group.present? %>
57
+ <span class="badge badge-info"><%= feature.group %></span>
58
+ <% else %>
59
+ <em style="color: #999;">—</em>
60
+ <% end %>
61
+ </td>
34
62
  <td data-label="Type">
35
63
  <span class="badge badge-info">
36
64
  <%= feature.type.to_s.capitalize %>
@@ -18,6 +18,14 @@
18
18
  <span class="badge badge-info"><%= @feature.type.to_s.capitalize %></span>
19
19
  </div>
20
20
  </div>
21
+ <% if @feature.group.present? %>
22
+ <div class="detail-item">
23
+ <div class="detail-label">Group</div>
24
+ <div class="detail-value">
25
+ <span class="badge badge-info"><%= @feature.group %></span>
26
+ </div>
27
+ </div>
28
+ <% end %>
21
29
  <div class="detail-item">
22
30
  <div class="detail-label">Status</div>
23
31
  <div class="detail-value">
@@ -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
@@ -8,7 +8,7 @@ module Magick
8
8
  VALID_TYPES = %i[boolean string number].freeze
9
9
  VALID_STATUSES = %i[active inactive deprecated].freeze
10
10
 
11
- attr_reader :name, :type, :status, :default_value, :description, :display_name, :adapter_registry
11
+ attr_reader :name, :type, :status, :default_value, :description, :display_name, :group, :adapter_registry
12
12
 
13
13
  def initialize(name, adapter_registry, **options)
14
14
  @name = name.to_s
@@ -18,6 +18,7 @@ module Magick
18
18
  @default_value = options.fetch(:default_value, default_for_type)
19
19
  @description = options[:description]
20
20
  @display_name = options[:name] || options[:display_name]
21
+ @group = options[:group]
21
22
  @targeting = {}
22
23
  @dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
23
24
  @stored_value_initialized = false # Track if @stored_value has been explicitly set
@@ -88,6 +89,18 @@ module Magick
88
89
  end
89
90
 
90
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
+
91
104
  # Fast path: check status first
92
105
  return false if status == :inactive
93
106
  return false if status == :deprecated && !context[:allow_deprecated]
@@ -110,17 +123,14 @@ module Magick
110
123
 
111
124
  # Check user/group/role/percentage targeting
112
125
  targeting_result = check_targeting(context)
113
- if targeting_result.nil?
114
- # Targeting doesn't match - return false
115
- return false
116
- else
117
- # Targeting matches - for boolean features, return true directly
118
- # For string/number features, still check the value
119
- if type == :boolean
120
- return true
121
- end
122
- # For string/number, continue to check value below
123
- 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
+
124
134
  end
125
135
 
126
136
  # Get value and check based on type
@@ -169,23 +179,22 @@ module Magick
169
179
  # If targeting doesn't match (returns nil), continue to return default value
170
180
  unless targeting_result.nil?
171
181
  # Targeting matches - return stored value (or load it if not initialized)
172
- if @stored_value_initialized
173
- 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
174
191
  else
175
- # Load from adapter
176
- loaded_value = load_value_from_adapter
177
- if loaded_value.nil?
178
- # Value not found in adapter, use default and cache it
179
- @stored_value = default_value
180
- @stored_value_initialized = true
181
- return default_value
182
- else
183
- # Value found in adapter, use it and mark as initialized
184
- @stored_value = loaded_value
185
- @stored_value_initialized = true
186
- return loaded_value
187
- 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
188
196
  end
197
+
189
198
  end
190
199
  # Targeting doesn't match - return default value
191
200
  return default_value
@@ -243,6 +252,16 @@ module Magick
243
252
  true
244
253
  end
245
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
+
246
265
  def enable_percentage_of_users(percentage)
247
266
  @targeting[:percentage_users] = percentage.to_f
248
267
  save_targeting
@@ -405,6 +424,7 @@ module Magick
405
424
  adapter_registry.set(name, 'default_value', default_value)
406
425
  adapter_registry.set(name, 'description', description) if description
407
426
  adapter_registry.set(name, 'display_name', display_name) if display_name
427
+ adapter_registry.set(name, 'group', group) if group
408
428
  @stored_value = value
409
429
  @stored_value_initialized = true # Mark as initialized
410
430
 
@@ -516,6 +536,22 @@ module Magick
516
536
  true
517
537
  end
518
538
 
539
+ def set_group(group_name)
540
+ if group_name.nil? || group_name.to_s.strip.empty?
541
+ @group = nil
542
+ # Clear group from adapter by setting to empty string (adapters handle this)
543
+ adapter_registry.set(name, 'group', nil)
544
+ else
545
+ @group = group_name.to_s.strip
546
+ adapter_registry.set(name, 'group', @group)
547
+ end
548
+
549
+ # Update registered feature instance if it exists
550
+ Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name)
551
+
552
+ true
553
+ end
554
+
519
555
  def delete
520
556
  adapter_registry.delete(name)
521
557
  @stored_value = nil
@@ -541,6 +577,7 @@ module Magick
541
577
  registered.instance_variable_set(:@status, @status)
542
578
  registered.instance_variable_set(:@description, @description)
543
579
  registered.instance_variable_set(:@display_name, @display_name)
580
+ registered.instance_variable_set(:@group, @group)
544
581
  registered.instance_variable_set(:@targeting, @targeting.dup)
545
582
  registered.instance_variable_set(:@_targeting_empty, @_targeting_empty)
546
583
  registered.instance_variable_set(:@_perf_metrics_enabled, @_perf_metrics_enabled)
@@ -576,7 +613,7 @@ module Magick
576
613
  # Update local targeting empty cache for performance
577
614
  @_targeting_empty = targeting.empty?
578
615
 
579
- # 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:
580
617
  # 1. adapter_registry.set already publishes cache invalidation (synchronously for async Redis updates)
581
618
  # 2. Publishing twice causes duplicate reloads in other processes
582
619
  # 3. The set method handles both sync and async Redis updates correctly
@@ -619,6 +656,10 @@ module Magick
619
656
  @display_name = display_name_value if display_name_value
620
657
  end
621
658
 
659
+ # Load group from adapter (can be set via DSL or Admin UI)
660
+ group_value = adapter_registry.get(name, 'group')
661
+ @group = group_value if group_value
662
+
622
663
  targeting_value = adapter_registry.get(name, 'targeting')
623
664
  if targeting_value.is_a?(Hash)
624
665
  # Normalize keys to symbols and handle nested structures
@@ -636,9 +677,8 @@ module Magick
636
677
  # The features.rb file is the source of truth for metadata
637
678
  # This ensures metadata is always up-to-date even if feature already exists
638
679
  adapter_registry.set(name, 'description', @description) if @description
639
- return unless @display_name
640
-
641
- adapter_registry.set(name, 'display_name', @display_name)
680
+ adapter_registry.set(name, 'display_name', @display_name) if @display_name
681
+ adapter_registry.set(name, 'group', @group) if @group
642
682
  end
643
683
 
644
684
  def load_value_from_adapter
@@ -681,6 +721,14 @@ module Magick
681
721
  return true if role_list.include?(context[:role].to_s)
682
722
  end
683
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
+
684
732
  # Check percentage of users (consistent based on user_id)
685
733
  if context[:user_id] && target[:percentage_users]
686
734
  percentage = target[:percentage_users].to_f
@@ -806,10 +854,13 @@ module Magick
806
854
  context[:group] = object[:group] || object['group']
807
855
  context[:role] = object[:role] || object['role']
808
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
809
860
  # Include all other attributes for custom attribute matching
810
861
  object.each do |key, value|
811
- next if %i[user_id id group role ip_address].include?(key.to_sym)
812
- 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)
813
864
 
814
865
  context[key.to_sym] = value
815
866
  end
@@ -820,10 +871,32 @@ module Magick
820
871
  context[:role] = object.role if object.respond_to?(:role)
821
872
  context[:ip_address] = object.ip_address if object.respond_to?(:ip_address)
822
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
+
823
896
  # For ActiveRecord objects, include all attributes
824
897
  if object.respond_to?(:attributes)
825
898
  object.attributes.each do |key, value|
826
- 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)
827
900
 
828
901
  context[key.to_sym] = value
829
902
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '0.9.35'
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.35
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '2.22'
46
+ version: '3.8'
47
47
  type: :development
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '2.22'
53
+ version: '3.8'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activerecord
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -77,14 +77,14 @@ dependencies:
77
77
  requirements:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
- version: '1.6'
80
+ version: '2.0'
81
81
  type: :development
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
- version: '1.6'
87
+ version: '2.0'
88
88
  description: Magick is a better free version of Flipper feature-toggle gem. It is
89
89
  absolutely performant and memory efficient (by my opinion).
90
90
  email:
@@ -160,7 +160,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
160
160
  requirements:
161
161
  - - ">="
162
162
  - !ruby/object:Gem::Version
163
- version: 3.0.0
163
+ version: 3.2.0
164
164
  required_rubygems_version: !ruby/object:Gem::Requirement
165
165
  requirements:
166
166
  - - ">="