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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da40f567a605d55076aaeabed1edd404bb41ba2a1703c56595e42611ebf4aaa4
4
- data.tar.gz: 39df667c30500ebf560e90694213f05b479d60f488575c7f2503409bc640222d
3
+ metadata.gz: 56034c2de3a47fbbb96f97aa9f1149c87c58bc1558b50a7520db7b07a93c3e85
4
+ data.tar.gz: dc818a9034753adfa51b2cd810a5affe7c3cb9e30ff9943c67e0df01d0c6cf66
5
5
  SHA512:
6
- metadata.gz: 0bb2499ecf945fb2586918f5c3e57cdda5e6ce2f143eb4b6d1d674370412ccee6f6e8fe5d992705ac4b0b28de88465ebe7e140fad1a2c71f691982ebe363c4ce
7
- data.tar.gz: f255c179b502557624bfcfc81bc06743c13898bef9d7b5225e4519e097fbdfc7b88af2b2729088883747cb3a123f1a51fff521618527e68e7e77ca494212be32
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
- Typed feature flags for Rails. Booleans, strings, numbers, and JSON — with pluggable backends.
3
+ Feature flags that return strings, numbers, JSON — not just booleans.
4
4
 
5
- [Subflag](https://subflag.com)
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
- ## Backends
11
+ Target different values to different users:
8
12
 
9
- Choose where your flags live:
10
-
11
- | Backend | Use Case | Flags Stored In |
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
- **Same API regardless of backend:**
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
- subflag_enabled?(:new_checkout) # Works with any backend
21
- subflag_value(:max_projects, default: 3) # Works with any backend
25
+ # Premium users get 100, free users get 10
26
+ subflag_value(:max_projects, default: 10)
22
27
  ```
23
28
 
24
- ## Installation
29
+ Self-hosted or cloud. Same API.
25
30
 
26
- Add to your Gemfile:
31
+ ## Quick Start
27
32
 
28
33
  ```ruby
29
- gem 'subflag-rails'
34
+ gem "subflag-rails"
35
+ ```
30
36
 
31
- # If using Subflag Cloud (backend: :subflag), also add:
32
- gem 'subflag-openfeature-provider'
37
+ ```bash
38
+ rails generate subflag:install --backend=active_record
39
+ rails db:migrate
33
40
  ```
34
41
 
35
- ### Option 1: Subflag Cloud (Default)
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
- ### Option 2: ActiveRecord (Self-Hosted)
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
- When using `backend: :active_record`, flags are stored in the `subflag_flags` table:
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 | The flag value as a string |
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
+ ![Subflag Admin UI](https://raw.githubusercontent.com/subflag/sdk/main/packages/subflag-rails/docs/admin-ui.png)
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 %>