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.
@@ -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
@@ -30,11 +30,19 @@ module Subflag
30
30
  end
31
31
  end
32
32
 
33
+ # Helper for templates to detect PostgreSQL adapter
34
+ def postgresql?
35
+ return false unless defined?(::ActiveRecord::Base)
36
+
37
+ adapter = ::ActiveRecord::Base.connection_db_config.adapter.to_s rescue nil
38
+ adapter&.include?("postgresql") || adapter&.include?("postgis")
39
+ end
40
+
33
41
  def create_initializer
34
42
  template "initializer.rb.tt", "config/initializers/subflag.rb"
35
43
  end
36
44
 
37
- def create_migration
45
+ def create_flags_migration
38
46
  return unless options[:backend] == "active_record"
39
47
 
40
48
  migration_template "create_subflag_flags.rb.tt",
@@ -9,6 +9,11 @@ class CreateSubflagFlags < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
9
9
  t.boolean :enabled, null: false, default: true
10
10
  t.text :description
11
11
 
12
+ # Targeting rules for showing different values to different users
13
+ # Stores an array of { value, conditions } rules as JSON
14
+ # First matching rule wins; falls back to `value` if no match
15
+ t.<%= postgresql? ? 'jsonb' : 'json' %> :targeting_rules
16
+
12
17
  t.timestamps
13
18
  end
14
19
 
@@ -3,26 +3,32 @@
3
3
  module Subflag
4
4
  module Rails
5
5
  module Backends
6
- # Provider that reads flags from your Rails database
6
+ # Provider that reads flags from your Rails database with targeting support.
7
7
  #
8
- # Stores flags in a `subflag_flags` table with typed values.
9
- # Perfect for teams who want self-hosted feature flags without external dependencies.
8
+ # Stores flags in a `subflag_flags` table with typed values and optional
9
+ # targeting rules for showing different values to different users.
10
10
  #
11
- # @example
11
+ # @example Basic setup
12
12
  # Subflag::Rails.configure do |config|
13
13
  # config.backend = :active_record
14
14
  # end
15
15
  #
16
- # # Create a flag
16
+ # @example Create a simple flag
17
17
  # Subflag::Rails::Flag.create!(
18
18
  # key: "max-projects",
19
19
  # value: "100",
20
- # value_type: "integer",
21
- # enabled: true
20
+ # value_type: "integer"
22
21
  # )
23
22
  #
24
- # # Use it
25
- # subflag_value(:max_projects, default: 3) # => 100
23
+ # @example Create a flag with targeting rules
24
+ # Subflag::Rails::Flag.create!(
25
+ # key: "new-dashboard",
26
+ # value: "false",
27
+ # value_type: "boolean",
28
+ # targeting_rules: [
29
+ # { "value" => "true", "conditions" => { "type" => "AND", "conditions" => [{ "attribute" => "email", "operator" => "ENDS_WITH", "value" => "@company.com" }] } }
30
+ # ]
31
+ # )
26
32
  #
27
33
  class ActiveRecordProvider
28
34
  def metadata
@@ -33,40 +39,65 @@ module Subflag
33
39
  def shutdown; end
34
40
 
35
41
  def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
36
- resolve(flag_key, default_value, :boolean)
42
+ resolve(flag_key, default_value, :boolean, evaluation_context)
37
43
  end
38
44
 
39
45
  def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
40
- resolve(flag_key, default_value, :string)
46
+ resolve(flag_key, default_value, :string, evaluation_context)
41
47
  end
42
48
 
43
49
  def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
44
- resolve(flag_key, default_value, :number)
50
+ resolve(flag_key, default_value, :number, evaluation_context)
45
51
  end
46
52
 
47
53
  def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
48
- resolve(flag_key, default_value, :integer)
54
+ resolve(flag_key, default_value, :integer, evaluation_context)
49
55
  end
50
56
 
51
57
  def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
52
- resolve(flag_key, default_value, :float)
58
+ resolve(flag_key, default_value, :float, evaluation_context)
53
59
  end
54
60
 
55
61
  def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
56
- resolve(flag_key, default_value, :object)
62
+ resolve(flag_key, default_value, :object, evaluation_context)
57
63
  end
58
64
 
59
65
  private
60
66
 
61
- def resolve(flag_key, default_value, expected_type)
67
+ def resolve(flag_key, default_value, expected_type, evaluation_context)
62
68
  flag = Subflag::Rails::Flag.find_by(key: flag_key)
63
69
 
64
70
  unless flag&.enabled?
65
71
  return resolution(default_value, reason: :default)
66
72
  end
67
73
 
68
- value = flag.typed_value(expected_type)
69
- resolution(value, reason: :static, variant: "default")
74
+ # Convert OpenFeature context to hash for targeting evaluation
75
+ context = context_to_hash(evaluation_context)
76
+ value = flag.evaluate(context: context, expected_type: expected_type)
77
+
78
+ # Determine if targeting matched
79
+ reason = flag.targeting_rules.present? && context.present? ? :targeting_match : :static
80
+ resolution(value, reason: reason, variant: "default")
81
+ end
82
+
83
+ def context_to_hash(evaluation_context)
84
+ return nil if evaluation_context.nil?
85
+
86
+ # OpenFeature::SDK::EvaluationContext stores fields as instance variables
87
+ # We need to extract them into a hash for our targeting engine
88
+ if evaluation_context.respond_to?(:to_h)
89
+ evaluation_context.to_h
90
+ elsif evaluation_context.respond_to?(:fields)
91
+ evaluation_context.fields
92
+ else
93
+ # Fallback: extract instance variables
94
+ hash = {}
95
+ evaluation_context.instance_variables.each do |var|
96
+ key = var.to_s.delete("@")
97
+ hash[key] = evaluation_context.instance_variable_get(var)
98
+ end
99
+ hash
100
+ end
70
101
  end
71
102
 
72
103
  def resolution(value, reason:, variant: nil)
@@ -48,6 +48,9 @@ module Subflag
48
48
  # Set to nil to disable cross-request caching (default).
49
49
  attr_accessor :cache_ttl
50
50
 
51
+ # @return [Proc, nil] Admin authentication callback for the admin UI
52
+ attr_reader :admin_auth_callback
53
+
51
54
  def initialize
52
55
  @backend = :subflag
53
56
  @api_key = nil
@@ -56,6 +59,7 @@ module Subflag
56
59
  @logging_enabled = false
57
60
  @log_level = :debug
58
61
  @cache_ttl = nil
62
+ @admin_auth_callback = nil
59
63
  end
60
64
 
61
65
  # Set the backend with validation
@@ -116,6 +120,25 @@ module Subflag
116
120
 
117
121
  @user_context_block.call(user)
118
122
  end
123
+
124
+ # Configure authentication for the admin UI
125
+ #
126
+ # @yield [controller] Block called before each admin action
127
+ # @yieldparam controller [ActionController::Base] The controller instance
128
+ #
129
+ # @example Require admin role
130
+ # config.admin_auth do
131
+ # redirect_to main_app.root_path unless current_user&.admin?
132
+ # end
133
+ #
134
+ # @example Use Devise authenticate
135
+ # config.admin_auth do
136
+ # authenticate_user!
137
+ # end
138
+ def admin_auth(&block)
139
+ @admin_auth_callback = block if block_given?
140
+ @admin_auth_callback
141
+ end
119
142
  end
120
143
  end
121
144
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ module Rails
5
+ # Mountable engine for the Subflag admin UI.
6
+ #
7
+ # Mount in your routes to get a web UI for managing flags:
8
+ #
9
+ # # config/routes.rb
10
+ # mount Subflag::Rails::Engine => "/subflag"
11
+ #
12
+ # The engine provides:
13
+ # - Flag CRUD (list, create, edit, delete)
14
+ # - Targeting rule builder
15
+ # - Rule testing interface
16
+ #
17
+ # Security: Configure authentication in an initializer:
18
+ #
19
+ # Subflag::Rails.configure do |config|
20
+ # config.admin_auth do |controller|
21
+ # controller.authenticate_admin! # Your auth method
22
+ # end
23
+ # end
24
+ #
25
+ class Engine < ::Rails::Engine
26
+ isolate_namespace Subflag::Rails
27
+
28
+ # Load engine routes
29
+ initializer "subflag.routes" do |app|
30
+ # Routes are loaded automatically from config/routes.rb
31
+ end
32
+ end
33
+ end
34
+ end