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 +4 -4
- data/README.md +75 -15
- data/app/controllers/magick/adminui/features_controller.rb +43 -1
- data/app/views/layouts/application.html.erb +275 -0
- data/app/views/magick/adminui/features/edit.html.erb +58 -5
- data/app/views/magick/adminui/features/index.html.erb +28 -0
- data/app/views/magick/adminui/features/show.html.erb +8 -0
- data/lib/magick/admin_ui.rb +9 -1
- data/lib/magick/dsl.rb +4 -0
- data/lib/magick/feature.rb +107 -34
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +1 -1
- metadata +6 -6
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`)
|
|
@@ -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. **
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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">
|
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
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/magick/version.rb
CHANGED
data/lib/magick.rb
CHANGED
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.
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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.
|
|
163
|
+
version: 3.2.0
|
|
164
164
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
165
165
|
requirements:
|
|
166
166
|
- - ">="
|