subflag-rails 0.5.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: bd3dfb04635e013ed8a9da2282150acf511c8704aace3c439a06f7e5dd0ac24a
4
- data.tar.gz: adc804044699b6cea75d8b44fc742d08f2a96b24092a8e66d19b419f41c45fa1
3
+ metadata.gz: 56034c2de3a47fbbb96f97aa9f1149c87c58bc1558b50a7520db7b07a93c3e85
4
+ data.tar.gz: dc818a9034753adfa51b2cd810a5affe7c3cb9e30ff9943c67e0df01d0c6cf66
5
5
  SHA512:
6
- metadata.gz: eb94613ee232272a8799ff0f7623c02d2d6e398ed0b7fbb231ff48bdf1e160ff99cb2110622567374012de4418e9b40129f7b14b0fabe7624904c399696f6dfc
7
- data.tar.gz: 6327d0185659e82b95a922380cfbae030a42f91676a5a00d1a5fb43120c46eedd8d88432c0ec5cc7d20724494abd57be11ac6c47e2da26b536daa159174bf9ac
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, [built-in admin UI](#admin-ui-activerecord) | 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,7 +356,9 @@ 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
  |--------|------|-------------|
@@ -486,6 +502,8 @@ Rails.application.routes.draw do
486
502
  end
487
503
  ```
488
504
 
505
+ ![Subflag Admin UI](https://raw.githubusercontent.com/subflag/sdk/main/packages/subflag-rails/docs/admin-ui.png)
506
+
489
507
  The admin UI provides:
490
508
  - List, create, edit, and delete flags
491
509
  - Toggle flags enabled/disabled
@@ -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 %>
@@ -0,0 +1,241 @@
1
+ <div class="flex justify-between items-center mb-10">
2
+ <h2 style="font-size: 20px;">Edit Flag: <span class="mono"><%= @flag.key %></span></h2>
3
+ <a href="<%= flags_path %>" class="btn">Back to Flags</a>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <h2>Basic Settings</h2>
8
+ <%= render "form", flag: @flag %>
9
+ </div>
10
+
11
+ <div class="card">
12
+ <h2>Targeting Rules</h2>
13
+ <p class="text-muted mb-10">Rules are evaluated in order. First match wins. If no rules match, the default value is used.</p>
14
+
15
+ <div id="rules-container">
16
+ <!-- Rules rendered by JS -->
17
+ </div>
18
+
19
+ <button type="button" class="btn mt-10" onclick="addRule()">+ Add Rule</button>
20
+
21
+ <input type="hidden" name="targeting_rules_json" id="targeting-rules-json" value="<%= @flag.targeting_rules.to_json %>">
22
+
23
+ <div class="mt-10">
24
+ <button type="button" class="btn btn-primary" onclick="saveRules()">Save Rules</button>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="card">
29
+ <h2>Test Rules</h2>
30
+ <p class="text-muted mb-10">Enter a context (JSON) to test which value would be returned.</p>
31
+
32
+ <%= form_with url: test_flag_path(@flag), method: :post, local: true do |f| %>
33
+ <div class="form-group">
34
+ <%= f.label :context, "Test Context (JSON)" %>
35
+ <%= f.text_area :context, rows: 4, placeholder: '{"email": "test@company.com", "role": "admin"}', value: @test_context&.to_json, class: "mono" %>
36
+ </div>
37
+ <%= f.submit "Test", class: "btn" %>
38
+ <% end %>
39
+
40
+ <% if defined?(@test_result) && @test_result %>
41
+ <div class="mt-10" style="padding: 15px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 4px;">
42
+ <strong>Result:</strong> <span class="mono"><%= @test_result.inspect %></span>
43
+ </div>
44
+ <% end %>
45
+ </div>
46
+
47
+ <style>
48
+ .rule { border: 1px solid #ddd; border-radius: 4px; padding: 15px; margin-bottom: 10px; background: #fafafa; }
49
+ .rule-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
50
+ .rule-value { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
51
+ .rule-value input { width: 200px; }
52
+ .rule-logic { margin-bottom: 10px; }
53
+ .rule-logic label { margin-right: 15px; }
54
+ .condition { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
55
+ .condition select, .condition input { padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; }
56
+ .condition input[type="text"] { width: 180px; }
57
+ .remove-btn { background: none; border: none; color: #dc3545; cursor: pointer; font-size: 18px; padding: 0 5px; }
58
+ .remove-btn:hover { color: #c82333; }
59
+ </style>
60
+
61
+ <script>
62
+ const OPERATORS = [
63
+ { value: "EQUALS", label: "equals" },
64
+ { value: "NOT_EQUALS", label: "not equals" },
65
+ { value: "IN", label: "in (comma-separated)" },
66
+ { value: "NOT_IN", label: "not in" },
67
+ { value: "CONTAINS", label: "contains" },
68
+ { value: "STARTS_WITH", label: "starts with" },
69
+ { value: "ENDS_WITH", label: "ends with" },
70
+ { value: "GREATER_THAN", label: ">" },
71
+ { value: "LESS_THAN", label: "<" },
72
+ { value: "MATCHES", label: "matches (regex)" }
73
+ ];
74
+
75
+ let rules = [];
76
+
77
+ function init() {
78
+ const saved = document.getElementById("targeting-rules-json").value;
79
+ if (saved) {
80
+ try {
81
+ rules = JSON.parse(saved) || [];
82
+ } catch (e) {
83
+ rules = [];
84
+ }
85
+ }
86
+ renderRules();
87
+ }
88
+
89
+ function renderRules() {
90
+ const container = document.getElementById("rules-container");
91
+ container.innerHTML = rules.map((rule, ruleIndex) => renderRule(rule, ruleIndex)).join("");
92
+ }
93
+
94
+ function renderRule(rule, ruleIndex) {
95
+ const conditions = rule.conditions?.conditions || [];
96
+ const logicType = rule.conditions?.type || "AND";
97
+
98
+ return `
99
+ <div class="rule" data-rule-index="${ruleIndex}">
100
+ <div class="rule-header">
101
+ <strong>Rule ${ruleIndex + 1}</strong>
102
+ <button type="button" class="remove-btn" onclick="removeRule(${ruleIndex})">&times;</button>
103
+ </div>
104
+ <div class="rule-value">
105
+ <label>Return value:</label>
106
+ <input type="text" value="${escapeHtml(rule.value || '')}" onchange="updateRuleValue(${ruleIndex}, this.value)" placeholder="value when matched">
107
+ </div>
108
+ <div class="rule-logic">
109
+ <label><input type="radio" name="logic-${ruleIndex}" value="AND" ${logicType === 'AND' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'AND')"> ALL match</label>
110
+ <label><input type="radio" name="logic-${ruleIndex}" value="OR" ${logicType === 'OR' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'OR')"> ANY match</label>
111
+ </div>
112
+ <div class="conditions" id="conditions-${ruleIndex}">
113
+ ${conditions.map((c, cIndex) => renderCondition(c, ruleIndex, cIndex)).join("")}
114
+ </div>
115
+ <button type="button" class="btn btn-sm" onclick="addCondition(${ruleIndex})">+ Add Condition</button>
116
+ </div>
117
+ `;
118
+ }
119
+
120
+ function renderCondition(condition, ruleIndex, condIndex) {
121
+ const operatorOptions = OPERATORS.map(op =>
122
+ `<option value="${op.value}" ${condition.operator === op.value ? 'selected' : ''}>${op.label}</option>`
123
+ ).join("");
124
+
125
+ const valueDisplay = Array.isArray(condition.value) ? condition.value.join(", ") : (condition.value || "");
126
+
127
+ return `
128
+ <div class="condition">
129
+ <input type="text" placeholder="attribute" value="${escapeHtml(condition.attribute || '')}" onchange="updateCondition(${ruleIndex}, ${condIndex}, 'attribute', this.value)">
130
+ <select onchange="updateCondition(${ruleIndex}, ${condIndex}, 'operator', this.value)">
131
+ ${operatorOptions}
132
+ </select>
133
+ <input type="text" placeholder="value" value="${escapeHtml(valueDisplay)}" onchange="updateCondition(${ruleIndex}, ${condIndex}, 'value', this.value)">
134
+ <button type="button" class="remove-btn" onclick="removeCondition(${ruleIndex}, ${condIndex})">&times;</button>
135
+ </div>
136
+ `;
137
+ }
138
+
139
+ function addRule() {
140
+ rules.push({
141
+ value: "",
142
+ conditions: { type: "AND", conditions: [{ attribute: "", operator: "EQUALS", value: "" }] }
143
+ });
144
+ renderRules();
145
+ }
146
+
147
+ function removeRule(index) {
148
+ rules.splice(index, 1);
149
+ renderRules();
150
+ }
151
+
152
+ function updateRuleValue(ruleIndex, value) {
153
+ rules[ruleIndex].value = value;
154
+ }
155
+
156
+ function updateLogic(ruleIndex, type) {
157
+ rules[ruleIndex].conditions.type = type;
158
+ }
159
+
160
+ function addCondition(ruleIndex) {
161
+ if (!rules[ruleIndex].conditions.conditions) {
162
+ rules[ruleIndex].conditions.conditions = [];
163
+ }
164
+ rules[ruleIndex].conditions.conditions.push({ attribute: "", operator: "EQUALS", value: "" });
165
+ renderRules();
166
+ }
167
+
168
+ function removeCondition(ruleIndex, condIndex) {
169
+ rules[ruleIndex].conditions.conditions.splice(condIndex, 1);
170
+ renderRules();
171
+ }
172
+
173
+ function updateCondition(ruleIndex, condIndex, field, value) {
174
+ const cond = rules[ruleIndex].conditions.conditions[condIndex];
175
+ if (field === "value" && (cond.operator === "IN" || cond.operator === "NOT_IN")) {
176
+ cond.value = value.split(",").map(v => v.trim());
177
+ } else {
178
+ cond[field] = value;
179
+ }
180
+ }
181
+
182
+ function saveRules() {
183
+ const form = document.createElement("form");
184
+ form.method = "POST";
185
+ form.action = "<%= flag_path(@flag) %>";
186
+
187
+ const methodInput = document.createElement("input");
188
+ methodInput.type = "hidden";
189
+ methodInput.name = "_method";
190
+ methodInput.value = "PATCH";
191
+ form.appendChild(methodInput);
192
+
193
+ const csrfInput = document.createElement("input");
194
+ csrfInput.type = "hidden";
195
+ csrfInput.name = "<%= request_forgery_protection_token %>";
196
+ csrfInput.value = "<%= form_authenticity_token %>";
197
+ form.appendChild(csrfInput);
198
+
199
+ // Send all flag params to preserve other fields
200
+ const keyInput = document.createElement("input");
201
+ keyInput.type = "hidden";
202
+ keyInput.name = "flag[key]";
203
+ keyInput.value = "<%= @flag.key %>";
204
+ form.appendChild(keyInput);
205
+
206
+ const valueInput = document.createElement("input");
207
+ valueInput.type = "hidden";
208
+ valueInput.name = "flag[value]";
209
+ valueInput.value = "<%= @flag.value %>";
210
+ form.appendChild(valueInput);
211
+
212
+ const typeInput = document.createElement("input");
213
+ typeInput.type = "hidden";
214
+ typeInput.name = "flag[value_type]";
215
+ typeInput.value = "<%= @flag.value_type %>";
216
+ form.appendChild(typeInput);
217
+
218
+ const enabledInput = document.createElement("input");
219
+ enabledInput.type = "hidden";
220
+ enabledInput.name = "flag[enabled]";
221
+ enabledInput.value = "<%= @flag.enabled? %>";
222
+ form.appendChild(enabledInput);
223
+
224
+ const rulesInput = document.createElement("input");
225
+ rulesInput.type = "hidden";
226
+ rulesInput.name = "flag[targeting_rules]";
227
+ rulesInput.value = JSON.stringify(rules);
228
+ form.appendChild(rulesInput);
229
+
230
+ document.body.appendChild(form);
231
+ form.submit();
232
+ }
233
+
234
+ function escapeHtml(str) {
235
+ const div = document.createElement("div");
236
+ div.textContent = str;
237
+ return div.innerHTML;
238
+ }
239
+
240
+ document.addEventListener("DOMContentLoaded", init);
241
+ </script>
@@ -0,0 +1,50 @@
1
+ <div class="flex justify-between items-center mb-10">
2
+ <h2 style="font-size: 20px;">Feature Flags</h2>
3
+ <a href="<%= new_flag_path %>" class="btn btn-primary">New Flag</a>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <% if @flags.any? %>
8
+ <table>
9
+ <thead>
10
+ <tr>
11
+ <th>Key</th>
12
+ <th>Type</th>
13
+ <th>Default Value</th>
14
+ <th>Status</th>
15
+ <th>Rules</th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @flags.each do |flag| %>
21
+ <tr>
22
+ <td class="mono"><%= flag.key %></td>
23
+ <td><%= flag.value_type %></td>
24
+ <td class="mono"><%= truncate(flag.value.to_s, length: 30) %></td>
25
+ <td>
26
+ <% if flag.enabled? %>
27
+ <span class="badge badge-success">enabled</span>
28
+ <% else %>
29
+ <span class="badge badge-secondary">disabled</span>
30
+ <% end %>
31
+ </td>
32
+ <td>
33
+ <% rules_count = flag.targeting_rules&.size || 0 %>
34
+ <span class="text-muted"><%= rules_count %> rule<%= rules_count == 1 ? '' : 's' %></span>
35
+ </td>
36
+ <td>
37
+ <div class="actions">
38
+ <a href="<%= edit_flag_path(flag) %>" class="btn btn-sm">Edit</a>
39
+ <%= button_to flag.enabled? ? "Disable" : "Enable", toggle_flag_path(flag), method: :post, class: "btn btn-sm" %>
40
+ <%= button_to "Delete", flag_path(flag), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Delete flag '#{flag.key}'?" } %>
41
+ </div>
42
+ </td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ <% else %>
48
+ <p class="text-muted">No flags yet. <a href="<%= new_flag_path %>">Create your first flag</a>.</p>
49
+ <% end %>
50
+ </div>
@@ -0,0 +1,5 @@
1
+ <h2 style="font-size: 20px; margin-bottom: 20px;">New Flag</h2>
2
+
3
+ <div class="card">
4
+ <%= render "form", flag: @flag %>
5
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Subflag::Rails::Engine.routes.draw do
4
+ resources :flags do
5
+ member do
6
+ post :toggle
7
+ post :test
8
+ end
9
+ end
10
+
11
+ root to: "flags#index"
12
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.5.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subflag
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-11 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: openfeature-sdk
@@ -170,7 +170,7 @@ dependencies:
170
170
  - - ">="
171
171
  - !ruby/object:Gem::Version
172
172
  version: '6.1'
173
- description: Feature flags for Rails with pluggable backends. Use Subflag Cloud (SaaS),
173
+ description: Feature flags for Rails with selectable backends. Use Subflag Cloud (SaaS),
174
174
  ActiveRecord (self-hosted), or Memory (testing). Get typed values (boolean, string,
175
175
  integer, double, object) with the same API regardless of backend.
176
176
  email:
@@ -182,6 +182,14 @@ files:
182
182
  - CHANGELOG.md
183
183
  - LICENSE.txt
184
184
  - README.md
185
+ - app/controllers/subflag/rails/application_controller.rb
186
+ - app/controllers/subflag/rails/flags_controller.rb
187
+ - app/views/layouts/subflag/rails/application.html.erb
188
+ - app/views/subflag/rails/flags/_form.html.erb
189
+ - app/views/subflag/rails/flags/edit.html.erb
190
+ - app/views/subflag/rails/flags/index.html.erb
191
+ - app/views/subflag/rails/flags/new.html.erb
192
+ - config/routes.rb
185
193
  - lib/generators/subflag/install_generator.rb
186
194
  - lib/generators/subflag/templates/create_subflag_flags.rb.tt
187
195
  - lib/generators/subflag/templates/initializer.rb.tt