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 +4 -4
- data/README.md +39 -5
- data/app/controllers/magick/adminui/features_controller.rb +20 -1
- data/app/views/layouts/application.html.erb +275 -0
- data/app/views/magick/adminui/features/edit.html.erb +52 -5
- data/lib/magick/admin_ui.rb +9 -1
- data/lib/magick/dsl.rb +4 -0
- data/lib/magick/feature.rb +82 -33
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b702af543d2f50e7f5b7ba6917ed318cff4e0b1fd82ef355a8aac757a18be75d
|
|
4
|
+
data.tar.gz: 8d68c5bb592755731f46a681a76d4b9fa092ddd5b46300042c8225fa6fffb90b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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. **
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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">
|
data/lib/magick/admin_ui.rb
CHANGED
|
@@ -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
|
data/lib/magick/feature.rb
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/magick/version.rb
CHANGED
data/lib/magick.rb
CHANGED