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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +56 -38
- 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/subflag/rails/version.rb +1 -1
- metadata +11 -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, [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
|
-
|
|
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,7 +356,9 @@ 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
|
|--------|------|-------------|
|
|
@@ -486,6 +502,8 @@ Rails.application.routes.draw do
|
|
|
486
502
|
end
|
|
487
503
|
```
|
|
488
504
|
|
|
505
|
+

|
|
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})">×</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})">×</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>
|
data/config/routes.rb
ADDED
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.
|
|
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
|
+
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
|
|
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
|