subflag-rails 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +202 -39
- data/app/controllers/subflag/rails/application_controller.rb +22 -0
- data/app/controllers/subflag/rails/flags_controller.rb +85 -0
- data/app/views/layouts/subflag/rails/application.html.erb +72 -0
- data/app/views/subflag/rails/flags/_form.html.erb +45 -0
- data/app/views/subflag/rails/flags/edit.html.erb +241 -0
- data/app/views/subflag/rails/flags/index.html.erb +50 -0
- data/app/views/subflag/rails/flags/new.html.erb +5 -0
- data/config/routes.rb +12 -0
- data/lib/generators/subflag/install_generator.rb +9 -1
- data/lib/generators/subflag/templates/create_subflag_flags.rb.tt +5 -0
- data/lib/subflag/rails/backends/active_record_provider.rb +49 -18
- data/lib/subflag/rails/configuration.rb +23 -0
- data/lib/subflag/rails/engine.rb +34 -0
- data/lib/subflag/rails/models/flag.rb +106 -17
- data/lib/subflag/rails/targeting.rb +48 -0
- data/lib/subflag/rails/targeting_engine.rb +191 -0
- data/lib/subflag/rails/version.rb +1 -1
- data/lib/subflag/rails.rb +2 -0
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56034c2de3a47fbbb96f97aa9f1149c87c58bc1558b50a7520db7b07a93c3e85
|
|
4
|
+
data.tar.gz: dc818a9034753adfa51b2cd810a5affe7c3cb9e30ff9943c67e0df01d0c6cf66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca26926074b168811199ae8fe3e065843073dcbae20667a9951a641bfb0e02e0d30e11ed16ae7376a1cf75c5f8ffc46e8012929494ba7d142a80260732ee4e0c
|
|
7
|
+
data.tar.gz: c8b0af027aa0cf5d01bdac26b2a7681c05fa818bfe2a92d1d178a3c8cd67cc0d94f555b495b132593009c2272ad3b3b60b61b6b93087109321c99bffdef446a3
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.1] - 2025-12-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Admin UI not loading**: Include `app/` and `config/` directories in gem package
|
|
10
|
+
- Previously only `lib/` was packaged, causing mounted Engine to fail
|
|
11
|
+
|
|
12
|
+
## [0.5.0] - 2025-12-11
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Admin UI**: Mount at `/subflag` to manage flags visually
|
|
17
|
+
- List, create, edit, and delete flags
|
|
18
|
+
- Toggle flags enabled/disabled
|
|
19
|
+
- Visual targeting rule builder (no JSON editing required)
|
|
20
|
+
- Test rules against sample contexts
|
|
21
|
+
- Configurable authentication via `config.admin_auth`
|
|
22
|
+
- **Targeting rules for ActiveRecord backend**: Return different values based on user attributes
|
|
23
|
+
- 12 comparison operators: EQUALS, NOT_EQUALS, IN, NOT_IN, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, MATCHES
|
|
24
|
+
- AND/OR condition groups
|
|
25
|
+
- First-match evaluation order
|
|
26
|
+
- **TargetingEngine**: Evaluates rules against user context
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `subflag_flags` table now includes `targeting_rules` column (JSON/JSONB)
|
|
31
|
+
- Generator creates migration with JSONB for PostgreSQL, JSON for other databases
|
|
32
|
+
|
|
33
|
+
## [0.4.0] - 2025-12-09
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Selectable backends**: Choose where flags are stored
|
|
38
|
+
- `:subflag` - Subflag Cloud (default)
|
|
39
|
+
- `:active_record` - Self-hosted, flags in your database
|
|
40
|
+
- `:memory` - In-memory for testing
|
|
41
|
+
- **ActiveRecord backend**: Store flags in `subflag_flags` table
|
|
42
|
+
- **Memory backend**: Programmatic flag management for tests
|
|
43
|
+
- Generator `--backend` option to configure storage
|
|
44
|
+
|
|
5
45
|
## [0.3.0] - 2025-12-07
|
|
6
46
|
|
|
7
47
|
### Added
|
data/README.md
CHANGED
|
@@ -1,41 +1,72 @@
|
|
|
1
1
|
# Subflag Rails
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Feature flags that return strings, numbers, JSON — not just booleans.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```ruby
|
|
6
|
+
limit = subflag_value(:upload_limit_mb, default: 10)
|
|
7
|
+
tier_config = subflag_value(:pricing_tiers, default: { basic: 5, pro: 50 })
|
|
8
|
+
welcome = subflag_value(:welcome_message, default: "Hello")
|
|
9
|
+
```
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
Target different values to different users:
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
| `:subflag` | Production with dashboard, environments, targeting | Subflag Cloud |
|
|
14
|
-
| `:active_record` | Self-hosted, no external dependencies | Your database |
|
|
15
|
-
| `:memory` | Testing and development | In-memory hash |
|
|
13
|
+
```ruby
|
|
14
|
+
# config/initializers/subflag.rb
|
|
15
|
+
Subflag::Rails.configure do |config|
|
|
16
|
+
config.backend = :active_record
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
config.user_context do |user|
|
|
19
|
+
{ targeting_key: user.id.to_s, plan: user.plan, email: user.email }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
18
23
|
|
|
19
24
|
```ruby
|
|
20
|
-
|
|
21
|
-
subflag_value(:max_projects, default:
|
|
25
|
+
# Premium users get 100, free users get 10
|
|
26
|
+
subflag_value(:max_projects, default: 10)
|
|
22
27
|
```
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
Self-hosted or cloud. Same API.
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
## Quick Start
|
|
27
32
|
|
|
28
33
|
```ruby
|
|
29
|
-
gem
|
|
34
|
+
gem "subflag-rails"
|
|
35
|
+
```
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
```bash
|
|
38
|
+
rails generate subflag:install --backend=active_record
|
|
39
|
+
rails db:migrate
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
```ruby
|
|
43
|
+
# config/routes.rb
|
|
44
|
+
mount Subflag::Rails::Engine => "/subflag"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No external dependencies. Admin UI included.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Backends
|
|
52
|
+
|
|
53
|
+
Choose where your flags live:
|
|
54
|
+
|
|
55
|
+
| Backend | Use Case | Flags Stored In |
|
|
56
|
+
|---------|----------|-----------------|
|
|
57
|
+
| `:active_record` | Self-hosted, no external dependencies, [built-in admin UI](#admin-ui-activerecord) | Your database |
|
|
58
|
+
| `:subflag` | Dashboard, environments, percentage rollouts | [Subflag Cloud](https://subflag.com) |
|
|
59
|
+
| `:memory` | Testing and development | In-memory hash |
|
|
60
|
+
|
|
61
|
+
### Subflag Cloud
|
|
36
62
|
|
|
37
63
|
Dashboard, environments, percentage rollouts, and user targeting.
|
|
38
64
|
|
|
65
|
+
```ruby
|
|
66
|
+
gem "subflag-rails"
|
|
67
|
+
gem "subflag-openfeature-provider"
|
|
68
|
+
```
|
|
69
|
+
|
|
39
70
|
```bash
|
|
40
71
|
rails generate subflag:install
|
|
41
72
|
```
|
|
@@ -53,24 +84,7 @@ subflag:
|
|
|
53
84
|
|
|
54
85
|
Or set the `SUBFLAG_API_KEY` environment variable.
|
|
55
86
|
|
|
56
|
-
###
|
|
57
|
-
|
|
58
|
-
Flags stored in your database. No external dependencies.
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
rails generate subflag:install --backend=active_record
|
|
62
|
-
rails db:migrate
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Create flags directly:
|
|
66
|
-
|
|
67
|
-
```ruby
|
|
68
|
-
Subflag::Rails::Flag.create!(key: "new-checkout", value: "true", value_type: "boolean")
|
|
69
|
-
Subflag::Rails::Flag.create!(key: "max-projects", value: "100", value_type: "integer")
|
|
70
|
-
Subflag::Rails::Flag.create!(key: "welcome-message", value: "Hello!", value_type: "string")
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
### Option 3: Memory (Testing)
|
|
87
|
+
### Memory (Testing)
|
|
74
88
|
|
|
75
89
|
In-memory flags for tests and local development.
|
|
76
90
|
|
|
@@ -342,15 +356,18 @@ end
|
|
|
342
356
|
|
|
343
357
|
### ActiveRecord Flag Model
|
|
344
358
|
|
|
345
|
-
|
|
359
|
+
> **Note:** This section only applies when using `backend: :active_record`. For Subflag Cloud, manage flags in the [dashboard](https://app.subflag.com).
|
|
360
|
+
|
|
361
|
+
Flags are stored in the `subflag_flags` table:
|
|
346
362
|
|
|
347
363
|
| Column | Type | Description |
|
|
348
364
|
|--------|------|-------------|
|
|
349
365
|
| `key` | string | Flag name (lowercase, dashes, e.g., `new-checkout`) |
|
|
350
|
-
| `value` | text |
|
|
366
|
+
| `value` | text | Default value (what everyone gets) |
|
|
351
367
|
| `value_type` | string | Type: `boolean`, `string`, `integer`, `float`, `object` |
|
|
352
368
|
| `enabled` | boolean | Whether the flag is active (default: true) |
|
|
353
369
|
| `description` | text | Optional description |
|
|
370
|
+
| `targeting_rules` | json | Optional rules for showing different values to different users |
|
|
354
371
|
|
|
355
372
|
```ruby
|
|
356
373
|
# Create flags
|
|
@@ -363,6 +380,152 @@ Subflag::Rails::Flag.enabled.find_each { |f| puts "#{f.key}: #{f.typed_value}" }
|
|
|
363
380
|
Subflag::Rails::Flag.find_by(key: "new-checkout")&.update!(enabled: false)
|
|
364
381
|
```
|
|
365
382
|
|
|
383
|
+
### Targeting Rules (ActiveRecord)
|
|
384
|
+
|
|
385
|
+
Show different flag values to different users based on their attributes. Perfect for internal testing before wider rollout.
|
|
386
|
+
|
|
387
|
+
> **Tip:** Use the [Admin UI](#admin-ui-activerecord) to manage targeting rules visually instead of editing JSON.
|
|
388
|
+
|
|
389
|
+
**First, configure user context:**
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
# config/initializers/subflag.rb
|
|
393
|
+
Subflag::Rails.configure do |config|
|
|
394
|
+
config.backend = :active_record
|
|
395
|
+
|
|
396
|
+
config.user_context do |user|
|
|
397
|
+
{
|
|
398
|
+
targeting_key: user.id.to_s,
|
|
399
|
+
email: user.email,
|
|
400
|
+
role: user.role, # e.g., "admin", "developer", "qa"
|
|
401
|
+
plan: user.plan # e.g., "free", "pro", "enterprise"
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Create flags with targeting rules:**
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# Internal team sees new feature, everyone else sees old
|
|
411
|
+
Subflag::Rails::Flag.create!(
|
|
412
|
+
key: "new-dashboard",
|
|
413
|
+
value: "false", # Default: everyone gets false
|
|
414
|
+
value_type: "boolean",
|
|
415
|
+
targeting_rules: [
|
|
416
|
+
{
|
|
417
|
+
"value" => "true", # Internal team gets true
|
|
418
|
+
"conditions" => {
|
|
419
|
+
"type" => "OR",
|
|
420
|
+
"conditions" => [
|
|
421
|
+
{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" },
|
|
422
|
+
{ "attribute" => "role", "operator" => "IN", "value" => ["admin", "developer", "qa"] }
|
|
423
|
+
]
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**Progressive rollout with multiple rules (first match wins):**
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
Subflag::Rails::Flag.create!(
|
|
434
|
+
key: "max-projects",
|
|
435
|
+
value: "5", # Default: everyone gets 5
|
|
436
|
+
value_type: "integer",
|
|
437
|
+
targeting_rules: [
|
|
438
|
+
{ "value" => "1000", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }] } },
|
|
439
|
+
{ "value" => "100", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@yourcompany.com" }] } },
|
|
440
|
+
{ "value" => "25", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "plan", "operator" => "EQUALS", "value" => "pro" }] } }
|
|
441
|
+
]
|
|
442
|
+
)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Supported operators:**
|
|
446
|
+
|
|
447
|
+
| Operator | Example | Description |
|
|
448
|
+
|----------|---------|-------------|
|
|
449
|
+
| `EQUALS` | `{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }` | Exact match |
|
|
450
|
+
| `NOT_EQUALS` | `{ "attribute" => "env", "operator" => "NOT_EQUALS", "value" => "prod" }` | Not equal |
|
|
451
|
+
| `IN` | `{ "attribute" => "role", "operator" => "IN", "value" => ["admin", "qa"] }` | Value in list |
|
|
452
|
+
| `NOT_IN` | `{ "attribute" => "country", "operator" => "NOT_IN", "value" => ["RU", "CN"] }` | Value not in list |
|
|
453
|
+
| `CONTAINS` | `{ "attribute" => "email", "operator" => "CONTAINS", "value" => "test" }` | String contains |
|
|
454
|
+
| `NOT_CONTAINS` | `{ "attribute" => "email", "operator" => "NOT_CONTAINS", "value" => "spam" }` | String doesn't contain |
|
|
455
|
+
| `STARTS_WITH` | `{ "attribute" => "user_id", "operator" => "STARTS_WITH", "value" => "test-" }` | String prefix |
|
|
456
|
+
| `ENDS_WITH` | `{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }` | String suffix |
|
|
457
|
+
| `GREATER_THAN` | `{ "attribute" => "age", "operator" => "GREATER_THAN", "value" => 18 }` | Numeric greater than |
|
|
458
|
+
| `LESS_THAN` | `{ "attribute" => "items", "operator" => "LESS_THAN", "value" => 100 }` | Numeric less than |
|
|
459
|
+
| `GREATER_THAN_OR_EQUAL` | `{ "attribute" => "score", "operator" => "GREATER_THAN_OR_EQUAL", "value" => 80 }` | Numeric greater or equal |
|
|
460
|
+
| `LESS_THAN_OR_EQUAL` | `{ "attribute" => "score", "operator" => "LESS_THAN_OR_EQUAL", "value" => 50 }` | Numeric less or equal |
|
|
461
|
+
| `MATCHES` | `{ "attribute" => "email", "operator" => "MATCHES", "value" => ".*@company\\.com$" }` | Regex match |
|
|
462
|
+
|
|
463
|
+
**Combining conditions:**
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
# OR: any condition matches
|
|
467
|
+
{
|
|
468
|
+
"type" => "OR",
|
|
469
|
+
"conditions" => [
|
|
470
|
+
{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" },
|
|
471
|
+
{ "attribute" => "role", "operator" => "EQUALS", "value" => "admin" }
|
|
472
|
+
]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
# AND: all conditions must match
|
|
476
|
+
{
|
|
477
|
+
"type" => "AND",
|
|
478
|
+
"conditions" => [
|
|
479
|
+
{ "attribute" => "plan", "operator" => "EQUALS", "value" => "enterprise" },
|
|
480
|
+
{ "attribute" => "country", "operator" => "IN", "value" => ["US", "CA"] }
|
|
481
|
+
]
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**How evaluation works:**
|
|
486
|
+
|
|
487
|
+
1. Flag disabled? → return code default
|
|
488
|
+
2. For each rule (in order): if context matches → return rule's value
|
|
489
|
+
3. No rules matched? → return flag's default `value`
|
|
490
|
+
|
|
491
|
+
This lets you progressively widen access by adding rules, without changing existing ones.
|
|
492
|
+
|
|
493
|
+
### Admin UI (ActiveRecord)
|
|
494
|
+
|
|
495
|
+
Mount the admin UI to manage flags and targeting rules visually:
|
|
496
|
+
|
|
497
|
+
```ruby
|
|
498
|
+
# config/routes.rb
|
|
499
|
+
Rails.application.routes.draw do
|
|
500
|
+
mount Subflag::Rails::Engine => "/subflag"
|
|
501
|
+
# ... your other routes
|
|
502
|
+
end
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+

|
|
506
|
+
|
|
507
|
+
The admin UI provides:
|
|
508
|
+
- List, create, edit, and delete flags
|
|
509
|
+
- Toggle flags enabled/disabled
|
|
510
|
+
- Visual targeting rule builder (no JSON editing)
|
|
511
|
+
- Test rules against sample contexts
|
|
512
|
+
|
|
513
|
+
**Secure the admin UI:**
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
# config/initializers/subflag.rb
|
|
517
|
+
Subflag::Rails.configure do |config|
|
|
518
|
+
config.backend = :active_record
|
|
519
|
+
|
|
520
|
+
# Require authentication for admin UI
|
|
521
|
+
config.admin_auth do
|
|
522
|
+
redirect_to main_app.root_path unless current_user&.admin?
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Visit `/subflag` in your browser to access the admin UI.
|
|
528
|
+
|
|
366
529
|
## Testing
|
|
367
530
|
|
|
368
531
|
Stub flags in your tests:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
class ApplicationController < ActionController::Base
|
|
6
|
+
include ActionController::Flash
|
|
7
|
+
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
|
|
10
|
+
before_action :authenticate_admin!
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def authenticate_admin!
|
|
15
|
+
auth_callback = Subflag::Rails.configuration.admin_auth_callback
|
|
16
|
+
return unless auth_callback
|
|
17
|
+
|
|
18
|
+
instance_exec(&auth_callback)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
class FlagsController < ApplicationController
|
|
6
|
+
before_action :set_flag, only: %i[show edit update destroy toggle test]
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@flags = Flag.order(:key)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show; end
|
|
13
|
+
|
|
14
|
+
def new
|
|
15
|
+
@flag = Flag.new(enabled: true, value_type: "boolean", value: "false")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
@flag = Flag.new(flag_params)
|
|
20
|
+
if @flag.save
|
|
21
|
+
redirect_to flags_path, notice: "Flag created."
|
|
22
|
+
else
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def edit; end
|
|
28
|
+
|
|
29
|
+
def update
|
|
30
|
+
if @flag.update(flag_params)
|
|
31
|
+
redirect_to edit_flag_path(@flag), notice: "Flag updated."
|
|
32
|
+
else
|
|
33
|
+
render :edit, status: :unprocessable_entity
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def destroy
|
|
38
|
+
@flag.destroy
|
|
39
|
+
redirect_to flags_path, notice: "Flag deleted."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def toggle
|
|
43
|
+
@flag.update!(enabled: !@flag.enabled)
|
|
44
|
+
redirect_to flags_path, notice: "Flag #{@flag.enabled? ? 'enabled' : 'disabled'}."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test
|
|
48
|
+
context = parse_test_context(params[:context])
|
|
49
|
+
@test_result = @flag.evaluate(context: context)
|
|
50
|
+
@test_context = context
|
|
51
|
+
|
|
52
|
+
respond_to do |format|
|
|
53
|
+
format.html { render :edit }
|
|
54
|
+
format.json { render json: { result: @test_result, context: @test_context } }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def set_flag
|
|
61
|
+
@flag = Flag.find(params[:id])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def flag_params
|
|
65
|
+
params.require(:flag).permit(:key, :value, :value_type, :enabled, :description).tap do |p|
|
|
66
|
+
if params[:flag][:targeting_rules].present?
|
|
67
|
+
p[:targeting_rules] = JSON.parse(params[:flag][:targeting_rules])
|
|
68
|
+
else
|
|
69
|
+
p[:targeting_rules] = []
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
params.require(:flag).permit(:key, :value, :value_type, :enabled, :description, :targeting_rules)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_test_context(context_json)
|
|
77
|
+
return {} if context_json.blank?
|
|
78
|
+
|
|
79
|
+
JSON.parse(context_json).transform_keys(&:to_sym)
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
{}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Subflag Admin</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.5; }
|
|
10
|
+
a { color: #0066cc; text-decoration: none; }
|
|
11
|
+
a:hover { text-decoration: underline; }
|
|
12
|
+
|
|
13
|
+
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
|
14
|
+
.header { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 20px; margin-bottom: 20px; }
|
|
15
|
+
.header h1 { font-size: 18px; font-weight: 600; }
|
|
16
|
+
.header a { color: #333; }
|
|
17
|
+
|
|
18
|
+
.card { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 20px; margin-bottom: 20px; }
|
|
19
|
+
.card h2 { font-size: 16px; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
|
|
20
|
+
|
|
21
|
+
.notice { background: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
|
|
22
|
+
.alert { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
|
|
23
|
+
.errors { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px 15px; border-radius: 4px; margin-bottom: 20px; }
|
|
24
|
+
.errors ul { margin-left: 20px; }
|
|
25
|
+
|
|
26
|
+
table { width: 100%; border-collapse: collapse; }
|
|
27
|
+
th, td { text-align: left; padding: 10px; border-bottom: 1px solid #eee; }
|
|
28
|
+
th { font-weight: 600; color: #666; font-size: 12px; text-transform: uppercase; }
|
|
29
|
+
|
|
30
|
+
.btn { display: inline-block; padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: #fff; color: #333; cursor: pointer; font-size: 14px; }
|
|
31
|
+
.btn:hover { background: #f5f5f5; text-decoration: none; }
|
|
32
|
+
.btn-primary { background: #0066cc; border-color: #0066cc; color: #fff; }
|
|
33
|
+
.btn-primary:hover { background: #0055aa; }
|
|
34
|
+
.btn-danger { background: #dc3545; border-color: #dc3545; color: #fff; }
|
|
35
|
+
.btn-danger:hover { background: #c82333; }
|
|
36
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
37
|
+
|
|
38
|
+
.form-group { margin-bottom: 15px; }
|
|
39
|
+
.form-group label { display: block; margin-bottom: 5px; font-weight: 500; font-size: 14px; }
|
|
40
|
+
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
|
41
|
+
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: #0066cc; }
|
|
42
|
+
.form-group small { color: #666; font-size: 12px; display: block; margin-top: 4px; }
|
|
43
|
+
|
|
44
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
45
|
+
.badge-success { background: #d4edda; color: #155724; }
|
|
46
|
+
.badge-secondary { background: #e9ecef; color: #6c757d; }
|
|
47
|
+
|
|
48
|
+
.actions { display: flex; gap: 8px; }
|
|
49
|
+
.mono { font-family: monospace; font-size: 13px; }
|
|
50
|
+
.text-muted { color: #666; }
|
|
51
|
+
.mt-10 { margin-top: 10px; }
|
|
52
|
+
.mb-10 { margin-bottom: 10px; }
|
|
53
|
+
.flex { display: flex; }
|
|
54
|
+
.justify-between { justify-content: space-between; }
|
|
55
|
+
.items-center { align-items: center; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div class="header">
|
|
60
|
+
<div class="container flex justify-between items-center" style="padding: 0;">
|
|
61
|
+
<h1><a href="<%= main_app.root_path rescue flags_path %>">Subflag</a></h1>
|
|
62
|
+
<a href="<%= flags_path %>">Flags</a>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="container">
|
|
67
|
+
<% if flash[:notice] %><div class="notice"><%= flash[:notice] %></div><% end %>
|
|
68
|
+
<% if flash[:alert] %><div class="alert"><%= flash[:alert] %></div><% end %>
|
|
69
|
+
<%= yield %>
|
|
70
|
+
</div>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<% if flag.errors.any? %>
|
|
2
|
+
<div class="errors">
|
|
3
|
+
<strong><%= pluralize(flag.errors.count, "error") %>:</strong>
|
|
4
|
+
<ul>
|
|
5
|
+
<% flag.errors.full_messages.each do |msg| %>
|
|
6
|
+
<li><%= msg %></li>
|
|
7
|
+
<% end %>
|
|
8
|
+
</ul>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<%= form_with model: flag, url: flag.persisted? ? flag_path(flag) : flags_path, local: true do |f| %>
|
|
13
|
+
<div class="form-group">
|
|
14
|
+
<%= f.label :key %>
|
|
15
|
+
<%= f.text_field :key, placeholder: "my-feature-flag", disabled: flag.persisted? %>
|
|
16
|
+
<small>Lowercase letters, numbers, and dashes only. Cannot be changed after creation.</small>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="form-group">
|
|
20
|
+
<%= f.label :value_type, "Type" %>
|
|
21
|
+
<%= f.select :value_type, [["Boolean", "boolean"], ["String", "string"], ["Integer", "integer"], ["Float", "float"], ["Object (JSON)", "object"]], {}, disabled: flag.persisted? %>
|
|
22
|
+
<small>Cannot be changed after creation.</small>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="form-group">
|
|
26
|
+
<%= f.label :value, "Default Value" %>
|
|
27
|
+
<%= f.text_field :value, placeholder: flag.value_type == "boolean" ? "true or false" : "default value" %>
|
|
28
|
+
<small>The value returned when no targeting rules match.</small>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="form-group">
|
|
32
|
+
<%= f.label :description %>
|
|
33
|
+
<%= f.text_area :description, rows: 2, placeholder: "Optional description..." %>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="form-group">
|
|
37
|
+
<%= f.label :enabled %>
|
|
38
|
+
<%= f.check_box :enabled %> Flag is enabled
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="mt-10">
|
|
42
|
+
<%= f.submit flag.persisted? ? "Update Flag" : "Create Flag", class: "btn btn-primary" %>
|
|
43
|
+
<a href="<%= flags_path %>" class="btn">Cancel</a>
|
|
44
|
+
</div>
|
|
45
|
+
<% end %>
|