wheel 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 519345603bb10712c2f342e0b96712fbc542dbf07b9a6fe84a8484f4bef67ad0
4
+ data.tar.gz: a27b4101c8ed51b2dc8aa583ddf6e24ffa697667aaf1c0851998ab47e1ee3f32
5
+ SHA512:
6
+ metadata.gz: 8d1ed06e4097e1edfb482696230a432401cce32f248ab06bf7f3a64c635bd9475f5f0d9af5f2fc4df106b8c3b848011bc30ea695189fa488ba4eda791251ab5d
7
+ data.tar.gz: bae00bc3778854e986bdc0a076df90c01e96ea4eaec95863b8e12b4c465321c0f95dafdcaf5867bdfb7f57b86ebbd23d2ae98d003a658bf52c45bc3b0fc18e8d
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Wheel 🛞
2
+
3
+ A Rails engine for managing dynamic configuration and feature flags with conditional rules.
4
+
5
+ It's time for you to take the wheel.
6
+
7
+ ## Features
8
+
9
+ - **Dynamic Configuration Storage**
10
+ - Store and manage configuration values with type safety
11
+ - Supported types: string, integer, float, boolean, and JSON
12
+ - In-memory caching for fast access
13
+ - Real-time configuration updates
14
+
15
+ - **Conditional Rules**
16
+ - Define rules based on context attributes
17
+ - Supported operators: equals, not_equals, in, not_in, matches, greater_than, less_than
18
+ - Multiple conditions per configuration
19
+ - Context-based evaluation
20
+
21
+ - **Web Interface**
22
+ - Modern UI for managing configurations
23
+ - Create and edit configuration values
24
+ - Define conditional rules
25
+ - Type-safe value editing
26
+
27
+ ## Installation
28
+
29
+ 1. Add to your Gemfile:
30
+
31
+ ```ruby
32
+ gem 'wheel'
33
+ ```
34
+
35
+ 2. Install:
36
+
37
+ ```bash
38
+ $ bundle install
39
+ ```
40
+
41
+ 3. Run the installer:
42
+
43
+ ```bash
44
+ $ rails generate wheel:install
45
+ $ rails db:migrate
46
+ ```
47
+
48
+ 4. Mount the engine in `config/routes.rb`:
49
+
50
+ ```ruby
51
+ Rails.application.routes.draw do
52
+ mount Wheel::Engine => "/wheel"
53
+ end
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ### Basic Configuration
59
+
60
+ ```ruby
61
+ # Get a configuration value
62
+ value = Wheel["feature.enabled"]
63
+
64
+ # Get a configuration with context
65
+ value = Wheel["pricing.tier", user_id: "123"]
66
+ ```
67
+
68
+ ### Using with Context
69
+
70
+ You can define default context attributes:
71
+
72
+ ```ruby
73
+ # config/initializers/wheel.rb
74
+ Wheel.configure do |config|
75
+ config.attributes = {
76
+ user_id: {
77
+ name: 'User ID',
78
+ description: 'The unique identifier of the user'
79
+ }
80
+ }
81
+ end
82
+ ```
83
+
84
+ ## Contributing
85
+
86
+ 1. Fork the repository
87
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
88
+ 3. Commit your changes (`git commit -am 'Add my feature'`)
89
+ 4. Push to the branch (`git push origin feature/my-feature`)
90
+ 5. Create a Pull Request
91
+
92
+ ## License
93
+
94
+ This project is licensed under the MIT License.
@@ -0,0 +1,65 @@
1
+ module Wheel
2
+ class ConfigsController < ApplicationController
3
+ layout 'wheel/application'
4
+
5
+ def index
6
+ @configs = Config.order(:key)
7
+ end
8
+
9
+ def new
10
+ @config = Config.new
11
+ end
12
+
13
+ def create
14
+ @config = Config.new(config_params)
15
+
16
+ if @config.save
17
+ Wheel.reload
18
+ redirect_to configs_path, notice: 'Config was successfully created.'
19
+ else
20
+ render :new
21
+ end
22
+ end
23
+
24
+ def edit
25
+ @config = Config.find(params[:id])
26
+ end
27
+
28
+ def update
29
+ @config = Config.find(params[:id])
30
+
31
+ if @config.update(config_params)
32
+ Wheel.reload
33
+ redirect_to configs_path, notice: 'Config was successfully updated.'
34
+ else
35
+ render :edit
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ @config = Config.find(params[:id])
41
+ @config.destroy
42
+ Wheel.reload
43
+
44
+ redirect_to configs_path, notice: 'Config was successfully deleted.'
45
+ end
46
+
47
+ private
48
+
49
+ def config_params
50
+ params.require(:config).permit(
51
+ :key,
52
+ :description,
53
+ :value_type,
54
+ :default_value,
55
+ :enabled,
56
+ conditions: [
57
+ :attribute,
58
+ :operator,
59
+ :match_value,
60
+ :return_value,
61
+ ]
62
+ )
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ module Wheel
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,105 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Wheel - Feature Flags & Config Management</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.css" rel="stylesheet" />
11
+ <script src="https://unpkg.com/lucide@latest"></script>
12
+ <style>
13
+ :root {
14
+ --color-primary: 234, 88, 12; /* Tailwind orange-600 */
15
+ --color-primary-50: rgb(255, 247, 237);
16
+ --color-primary-100: rgb(255, 237, 213);
17
+ --color-primary-200: rgb(254, 215, 170);
18
+ --color-primary-300: rgb(253, 186, 116);
19
+ --color-primary-400: rgb(251, 146, 60);
20
+ --color-primary-500: rgb(249, 115, 22);
21
+ --color-primary-600: rgb(234, 88, 12);
22
+ --color-primary-700: rgb(194, 65, 12);
23
+ --color-primary-800: rgb(154, 52, 18);
24
+ --color-primary-900: rgb(124, 45, 18);
25
+ }
26
+
27
+ .text-primary { color: rgb(var(--color-primary)); }
28
+ .text-primary-50 { color: var(--color-primary-50); }
29
+ .text-primary-100 { color: var(--color-primary-100); }
30
+ .text-primary-200 { color: var(--color-primary-200); }
31
+ .text-primary-300 { color: var(--color-primary-300); }
32
+ .text-primary-400 { color: var(--color-primary-400); }
33
+ .text-primary-500 { color: var(--color-primary-500); }
34
+ .text-primary-600 { color: var(--color-primary-600); }
35
+ .text-primary-700 { color: var(--color-primary-700); }
36
+ .text-primary-800 { color: var(--color-primary-800); }
37
+ .text-primary-900 { color: var(--color-primary-900); }
38
+
39
+ .bg-primary { background-color: rgb(var(--color-primary)); }
40
+ .bg-primary-50 { background-color: var(--color-primary-50); }
41
+ .bg-primary-100 { background-color: var(--color-primary-100); }
42
+ .bg-primary-200 { background-color: var(--color-primary-200); }
43
+ .bg-primary-300 { background-color: var(--color-primary-300); }
44
+ .bg-primary-400 { background-color: var(--color-primary-400); }
45
+ .bg-primary-500 { background-color: var(--color-primary-500); }
46
+ .bg-primary-600 { background-color: var(--color-primary-600); }
47
+ .bg-primary-700 { background-color: var(--color-primary-700); }
48
+ .bg-primary-800 { background-color: var(--color-primary-800); }
49
+ .bg-primary-900 { background-color: var(--color-primary-900); }
50
+
51
+ .hover\:text-primary:hover { color: rgb(var(--color-primary)); }
52
+ .hover\:text-primary-600:hover { color: var(--color-primary-600); }
53
+ .hover\:text-primary-700:hover { color: var(--color-primary-700); }
54
+
55
+ .hover\:bg-primary:hover { background-color: rgb(var(--color-primary)); }
56
+ .hover\:bg-primary-600:hover { background-color: var(--color-primary-600); }
57
+ .hover\:bg-primary-700:hover { background-color: var(--color-primary-700); }
58
+
59
+ .focus\:ring-primary:focus { --tw-ring-color: rgb(var(--color-primary)); }
60
+ .focus\:border-primary:focus { border-color: rgb(var(--color-primary)); }
61
+ </style>
62
+ </head>
63
+ <body class="min-h-screen bg-gray-50">
64
+ <nav class="bg-white shadow">
65
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
66
+ <div class="flex h-16 justify-between">
67
+ <div class="flex">
68
+ <div class="flex flex-shrink-0 items-center">
69
+ <a href="<%= wheel.root_path %>" class="text-xl font-bold text-primary-600 hover:text-primary-700">
70
+ Wheel
71
+ </a>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </nav>
77
+
78
+ <div class="py-10">
79
+ <main>
80
+ <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
81
+ <% if notice %>
82
+ <div id="alert-success" class="flex items-center p-4 mb-4 text-green-800 rounded-lg bg-green-50" role="alert">
83
+ <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
84
+ <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clip-rule="evenodd" />
85
+ </svg>
86
+ <div class="ml-3 text-sm font-medium">
87
+ <%= notice %>
88
+ </div>
89
+ <button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-green-50 text-green-500 rounded-lg focus:ring-2 focus:ring-green-400 p-1.5 hover:bg-green-200 inline-flex items-center justify-center h-8 w-8" data-dismiss-target="#alert-success" aria-label="Close">
90
+ <span class="sr-only">Close</span>
91
+ <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
92
+ <path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />
93
+ </svg>
94
+ </button>
95
+ </div>
96
+ <% end %>
97
+
98
+ <%= yield %>
99
+ </div>
100
+ </main>
101
+ </div>
102
+ </div>
103
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.2.1/flowbite.min.js"></script>
104
+ </body>
105
+ </html>
@@ -0,0 +1,314 @@
1
+ <%= form_with(model: config, class: "space-y-8") do |form| %>
2
+ <% if config.errors.any? %>
3
+ <div id="error_explanation" class="bg-red-50 p-4 rounded-lg">
4
+ <div class="flex">
5
+ <div class="ml-3">
6
+ <h3 class="text-sm font-medium text-red-800">
7
+ <%= pluralize(config.errors.count, "error") %> prohibited this config from being saved:
8
+ </h3>
9
+ <div class="mt-2 text-sm text-red-700">
10
+ <ul class="list-disc pl-5 space-y-1">
11
+ <% config.errors.each do |error| %>
12
+ <li><%= error.full_message %></li>
13
+ <% end %>
14
+ </ul>
15
+ </div>
16
+ </div>
17
+ </div>
18
+ </div>
19
+ <% end %>
20
+
21
+ <div class="bg-white shadow sm:rounded-lg">
22
+ <div class="px-4 py-5 sm:p-6">
23
+ <div class="space-y-6">
24
+ <div>
25
+ <h3 class="text-lg font-medium leading-6 text-gray-900">Basic Configuration</h3>
26
+ <div class="mt-2 max-w-xl text-sm text-gray-500">
27
+ <p>Set up your configuration key and its default behavior.</p>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
32
+ <div class="sm:col-span-4">
33
+ <%= form.label :key, class: "block text-sm font-medium text-gray-700" %>
34
+ <div class="mt-1 relative">
35
+ <%= form.text_field :key, class: "bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 pl-9", placeholder: "feature.my_key" %>
36
+ <i data-lucide="key" class="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5"></i>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="sm:col-span-6">
41
+ <%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
42
+ <div class="mt-1">
43
+ <%= form.text_area :description, rows: 3, class: "block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary focus:border-primary", placeholder: "Describe the purpose of this configuration..." %>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="sm:col-span-3">
48
+ <%= form.label :value_type, class: "block text-sm font-medium text-gray-700" %>
49
+ <div class="mt-1">
50
+ <%= form.select :value_type,
51
+ options_for_select([
52
+ ['String', 'string'],
53
+ ['Integer', 'integer'],
54
+ ['Float', 'float'],
55
+ ['Boolean', 'boolean'],
56
+ ['Array', 'array'],
57
+ ['JSON', 'json']
58
+ ], form.object.value_type),
59
+ {},
60
+ class: "bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5",
61
+ data: {
62
+ "value-type-target": "select",
63
+ action: "change->value-type#updateValueInputs"
64
+ } %>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="sm:col-span-4" data-controller="value-type">
69
+ <%= form.label :default_value, class: "block text-sm font-medium text-gray-700" %>
70
+ <div class="mt-1 relative" data-value-type-target="inputContainer">
71
+ <% case form.object.value_type %>
72
+ <% when 'boolean' %>
73
+ <%= form.select :default_value, [['True', 'true'], ['False', 'false']], {},
74
+ class: "bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5" %>
75
+ <% when 'array' %>
76
+ <div class="space-y-2" data-array-container>
77
+ <div class="array-items space-y-2">
78
+ <% (JSON.parse(form.object.default_value || '[]') rescue []).each do |item| %>
79
+ <div class="flex gap-2 array-item">
80
+ <div class="relative flex-1">
81
+ <input type="text" name="config[default_value][]" value="<%= item %>"
82
+ class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5">
83
+ <button type="button" onclick="removeArrayItem(this)"
84
+ class="inline-flex items-center text-sm text-gray-500 hover:text-red-600 whitespace-nowrap">
85
+ <i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
86
+ Remove
87
+ </button>
88
+ </div>
89
+ </div>
90
+ <% end %>
91
+ </div>
92
+ <button type="button" onclick="addArrayItem(this)" class="inline-flex items-center text-sm text-primary hover:text-primary-700">
93
+ <i data-lucide="plus" class="w-4 h-4 mr-1"></i>
94
+ Add Item
95
+ </button>
96
+ </div>
97
+ <% when 'json' %>
98
+ <div class="relative">
99
+ <%= form.text_area :default_value, rows: 4,
100
+ class: "font-mono bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5",
101
+ placeholder: '{"key": "value"}' %>
102
+ </div>
103
+ <% else %>
104
+ <div class="relative">
105
+ <%= form.text_field :default_value,
106
+ class: "bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5",
107
+ type: (form.object.value_type == 'integer' || form.object.value_type == 'float') ? 'number' : 'text',
108
+ step: form.object.value_type == 'float' ? '0.01' : '1' %>
109
+ </div>
110
+ <% end %>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <div class="pt-8">
116
+ <div>
117
+ <h3 class="text-lg font-medium leading-6 text-gray-900">Conditional Rules</h3>
118
+ <div class="mt-2 max-w-xl text-sm text-gray-500">
119
+ <p>Define rules to override the default value based on context.</p>
120
+ </div>
121
+ </div>
122
+
123
+ <div id="conditions" class="mt-4">
124
+ <div class="conditions-container space-y-4">
125
+ <!-- Existing conditions will be rendered here -->
126
+ </div>
127
+
128
+ <div class="mt-4">
129
+ <button type="button" onclick="addCondition()" class="w-full flex items-center justify-center py-3 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-primary hover:text-primary transition-colors">
130
+ <i data-lucide="plus" class="w-4 h-4 mr-2"></i>
131
+ Add New Condition
132
+ </button>
133
+ </div>
134
+ </div>
135
+ </div>
136
+
137
+ <div class="pt-5">
138
+ <div class="flex justify-end">
139
+ <%= link_to 'Cancel', configs_path, class: "bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary" %>
140
+ <%= form.submit class: "ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary" %>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <% end %>
147
+
148
+ <template id="condition-template">
149
+ <div class="condition-row bg-gray-50 shadow-sm rounded-lg border border-gray-200">
150
+ <div class="p-4">
151
+ <div class="flex items-center justify-between mb-4">
152
+ <div class="flex items-center space-x-2">
153
+ <i data-lucide="filter" class="w-4 h-4 text-gray-400"></i>
154
+ <h4 class="text-sm font-medium text-gray-900">Condition</h4>
155
+ </div>
156
+ <button type="button" onclick="this.closest('.condition-row').remove()" class="text-sm text-gray-500 hover:text-red-600 transition-colors">
157
+ <i data-lucide="trash-2" class="w-4 h-4"></i>
158
+ </button>
159
+ </div>
160
+
161
+ <div class="space-y-4">
162
+ <div class="grid grid-cols-1 md:grid-cols-12 gap-4">
163
+ <div class="md:col-span-3">
164
+ <label class="block text-sm font-medium text-gray-700 mb-1">When</label>
165
+ <select name="config[conditions][][attribute]" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5">
166
+ <% Wheel.attributes.each do |key, attr| %>
167
+ <option value="<%= key %>"><%= attr[:name] %></option>
168
+ <% end %>
169
+ </select>
170
+ </div>
171
+
172
+ <div class="md:col-span-3">
173
+ <label class="block text-sm font-medium text-gray-700 mb-1">Is</label>
174
+ <select name="config[conditions][][operator]" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5">
175
+ <option value="equals">Equals</option>
176
+ <option value="not_equals">Not Equals</option>
177
+ <option value="includes">Includes</option>
178
+ <option value="excludes">Excludes</option>
179
+ <option value="greater_than">Greater Than</option>
180
+ <option value="less_than">Less Than</option>
181
+ </select>
182
+ </div>
183
+
184
+ <div class="md:col-span-6">
185
+ <label class="block text-sm font-medium text-gray-700 mb-1">Value</label>
186
+ <input type="text" name="config[conditions][][match_value]" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5" placeholder="Enter match value">
187
+ </div>
188
+ </div>
189
+
190
+ <div class="border-t border-gray-200 mt-4 pt-4">
191
+ <label class="block text-sm font-medium text-gray-700 mb-2">
192
+ <i data-lucide="arrow-right" class="w-4 h-4 inline-block mr-1"></i>
193
+ Then return:
194
+ </label>
195
+ <div class="condition-value-container">
196
+ <!-- Dynamic return value input will be inserted here based on the selected value type -->
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </template>
203
+
204
+ <script>
205
+ function addCondition() {
206
+ const template = document.getElementById('condition-template')
207
+ const container = document.querySelector('.conditions-container')
208
+ const clone = template.content.cloneNode(true)
209
+
210
+ // Set the return value input based on the current value type
211
+ const valueType = document.querySelector('select[name="config[value_type]"]').value
212
+ const valueContainer = clone.querySelector('.condition-value-container')
213
+ valueContainer.innerHTML = createValueInput(valueType)
214
+
215
+ container.appendChild(clone)
216
+ lucide.createIcons()
217
+ }
218
+
219
+ function createValueInput(type) {
220
+ switch(type) {
221
+ case 'boolean':
222
+ return `<select name="config[conditions][][return_value]" class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5">
223
+ <option value="true">True</option>
224
+ <option value="false">False</option>
225
+ </select>`
226
+ case 'array':
227
+ return `<div class="space-y-2" data-array-container>
228
+ <div class="array-items space-y-2">
229
+ <div class="flex gap-2 array-item">
230
+ <input type="text" name="config[conditions][][return_value][]"
231
+ class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5"
232
+ placeholder="Enter array item">
233
+ <button type="button" onclick="removeArrayItem(this)"
234
+ class="inline-flex items-center text-sm text-gray-500 hover:text-red-600 whitespace-nowrap">
235
+ <i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
236
+ Remove
237
+ </button>
238
+ </div>
239
+ </div>
240
+ <button type="button" onclick="addArrayItem(this)" class="inline-flex items-center text-sm text-primary hover:text-primary-700">
241
+ <i data-lucide="plus" class="w-4 h-4 mr-1"></i>
242
+ Add Item
243
+ </button>
244
+ </div>`
245
+ case 'json':
246
+ return `<textarea name="config[conditions][][return_value]" rows="3"
247
+ class="font-mono bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5"
248
+ placeholder='{"key": "value"}'></textarea>`
249
+ case 'integer':
250
+ case 'float':
251
+ return `<input type="number" name="config[conditions][][return_value]"
252
+ step="${type === 'float' ? '0.01' : '1'}"
253
+ class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5"
254
+ placeholder="Enter return value">`
255
+ default:
256
+ return `<input type="text" name="config[conditions][][return_value]"
257
+ class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5"
258
+ placeholder="Enter return value">`
259
+ }
260
+ }
261
+
262
+ function removeArrayItem(button) {
263
+ const arrayItems = button.closest('.array-items')
264
+ if (arrayItems.children.length > 1) {
265
+ button.closest('.array-item').remove()
266
+ }
267
+ }
268
+
269
+ function addArrayItem(button) {
270
+ const container = button.previousElementSibling
271
+ const newItem = document.createElement('div')
272
+ newItem.className = 'flex gap-2 array-item'
273
+ newItem.innerHTML = `
274
+ <input type="text" name="config[conditions][][return_value][]"
275
+ class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5"
276
+ placeholder="Enter array item">
277
+ <button type="button" onclick="removeArrayItem(this)"
278
+ class="inline-flex items-center text-sm text-gray-500 hover:text-red-600 whitespace-nowrap">
279
+ <i data-lucide="trash-2" class="w-4 h-4 mr-1"></i>
280
+ Remove
281
+ </button>
282
+ `
283
+ container.appendChild(newItem)
284
+ lucide.createIcons()
285
+ }
286
+
287
+ document.addEventListener('DOMContentLoaded', function() {
288
+ // Add initial condition if none exist
289
+ const container = document.querySelector('.conditions-container')
290
+ if (container.children.length === 0) {
291
+ addCondition()
292
+ }
293
+
294
+ // Handle value type changes
295
+ const valueTypeSelect = document.querySelector('select[name="config[value_type]"]')
296
+ valueTypeSelect.addEventListener('change', function(e) {
297
+ const type = e.target.value
298
+
299
+ // Update default value input
300
+ const defaultContainer = document.querySelector('[data-value-type-target="inputContainer"]')
301
+ defaultContainer.innerHTML = createValueInput(type).replace(/conditions\[\]|match_value/g, 'default_value')
302
+
303
+ // Update all condition return value inputs
304
+ document.querySelectorAll('.condition-value-container').forEach(container => {
305
+ container.innerHTML = createValueInput(type)
306
+ })
307
+
308
+ lucide.createIcons()
309
+ })
310
+
311
+ // Initialize Lucide icons
312
+ lucide.createIcons()
313
+ })
314
+ </script>
@@ -0,0 +1,9 @@
1
+ <div class="md:flex md:items-center md:justify-between">
2
+ <div class="min-w-0 flex-1">
3
+ <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">Edit Configuration</h2>
4
+ </div>
5
+ </div>
6
+
7
+ <div class="mt-8">
8
+ <%= render 'form', config: @config %>
9
+ </div>
@@ -0,0 +1,68 @@
1
+ <div class="sm:flex sm:items-center">
2
+ <div class="sm:flex-auto">
3
+ <h1 class="text-xl font-semibold text-gray-900">Configuration & Feature Flags</h1>
4
+ <p class="mt-2 text-sm text-gray-700">Manage your application's configuration and feature flags.</p>
5
+ </div>
6
+ <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
7
+ <%= link_to 'New Config', new_config_path, class: "inline-flex items-center justify-center rounded-md border border-transparent bg-primary px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 sm:w-auto" %>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="mt-8 flex flex-col">
12
+ <div class="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
13
+ <div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
14
+ <div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
15
+ <table class="min-w-full divide-y divide-gray-300">
16
+ <thead class="bg-gray-50">
17
+ <tr>
18
+ <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Key</th>
19
+ <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
20
+ <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Default Value</th>
21
+ <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
22
+ <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Rules</th>
23
+ <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
24
+ <span class="sr-only">Actions</span>
25
+ </th>
26
+ </tr>
27
+ </thead>
28
+ <tbody class="divide-y divide-gray-200 bg-white">
29
+ <% @configs.each do |config| %>
30
+ <tr>
31
+ <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
32
+ <div><%= config.key %></div>
33
+ <div class="text-xs text-gray-500"><%= config.description %></div>
34
+ </td>
35
+ <td class="whitespace-nowrap px-3 py-4 text-sm">
36
+ <span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
37
+ <%= config.value_type %>
38
+ </span>
39
+ </td>
40
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
41
+ <%= config.default_value.to_s.truncate(30) %>
42
+ </td>
43
+ <td class="whitespace-nowrap px-3 py-4 text-sm">
44
+ <% if config.enabled %>
45
+ <span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
46
+ Enabled
47
+ </span>
48
+ <% else %>
49
+ <span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800">
50
+ Disabled
51
+ </span>
52
+ <% end %>
53
+ </td>
54
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
55
+ <%= pluralize(config.conditions&.size || 0, 'rule') %>
56
+ </td>
57
+ <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
58
+ <%= link_to 'Edit', edit_config_path(config), class: "text-primary hover:text-secondary" %>
59
+ <%= link_to 'Delete', config_path(config), method: :delete, data: { confirm: 'Are you sure?' }, class: "ml-4 text-red-600 hover:text-red-900" %>
60
+ </td>
61
+ </tr>
62
+ <% end %>
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="md:flex md:items-center md:justify-between">
2
+ <div class="min-w-0 flex-1">
3
+ <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">New Configuration</h2>
4
+ </div>
5
+ </div>
6
+
7
+ <div class="mt-8">
8
+ <%= render 'form', config: @config %></div>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ Wheel::Engine.routes.draw do
2
+ root to: 'configs#index'
3
+ resources :configs
4
+ end
@@ -0,0 +1,35 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module Wheel
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ def self.next_migration_number(dirname)
12
+ next_migration_number = current_migration_number(dirname) + 1
13
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
14
+ end
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ 'create_wheel_configs.rb',
19
+ 'db/migrate/create_wheel_configs.rb'
20
+ )
21
+ end
22
+
23
+ def mount_engine
24
+ route "mount Wheel::Engine => '/wheel'"
25
+ end
26
+
27
+ def create_initializer
28
+ template(
29
+ 'wheel.rb',
30
+ 'config/initializers/wheel.rb'
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,17 @@
1
+ class CreateWheelConfigs < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :wheel_configs do |t|
4
+ t.string :key, null: false
5
+ t.string :description
6
+ t.string :value_type, null: false
7
+ t.jsonb :default_value, null: false, default: {}
8
+ t.jsonb :conditions, default: []
9
+ t.boolean :enabled, default: true
10
+
11
+ t.timestamps
12
+
13
+ t.index :key, unique: true
14
+ t.index :conditions, using: :gin
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # Wheel configuration
2
+ Wheel.configure do |config|
3
+ # Configure the available attributes for conditional rules
4
+ config.attributes = {
5
+ # Default attribute that is always available
6
+ user_id: {
7
+ name: 'User ID',
8
+ description: 'The unique identifier of the user'
9
+ }
10
+
11
+ # Uncomment and customize additional attributes as needed:
12
+ # environment: {
13
+ # name: 'Environment',
14
+ # description: 'The deployment environment (e.g., development, staging, production)'
15
+ # },
16
+ # version: {
17
+ # name: 'Version',
18
+ # description: 'Application version number'
19
+ # },
20
+ # group: {
21
+ # name: 'Group',
22
+ # description: 'User group or role'
23
+ # }
24
+ }
25
+ end
@@ -0,0 +1,5 @@
1
+ module Wheel
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,128 @@
1
+ module Wheel
2
+ class Config < ApplicationRecord
3
+ self.table_name = 'wheel_configs'
4
+
5
+ validates :key, presence: true, uniqueness: true
6
+ validates :default_value, presence: true
7
+ validate :validate_conditions_format
8
+ validate :validate_value_type_consistency
9
+
10
+ # Evaluates the config value based on the given context
11
+ def evaluate(context = {})
12
+ return typed_default_value unless enabled?
13
+ return typed_default_value if conditions.blank?
14
+
15
+ matching_condition = conditions.find do |condition|
16
+ evaluate_condition(condition, context&.stringify_keys)
17
+ end
18
+
19
+ matching_condition ? type_cast_value(matching_condition['value']) : typed_default_value
20
+ end
21
+
22
+ private
23
+
24
+ def evaluate_condition(condition, context)
25
+ return false unless condition['rules'].is_a?(Array)
26
+
27
+ condition['rules'].all? do |rule|
28
+ context_value = context[rule['attribute'].to_s]
29
+ compare_values(context_value, rule['operator'], rule['value'])
30
+ end
31
+ end
32
+
33
+ def compare_values(context_value, operator, target_value)
34
+ case operator
35
+ when 'equals'
36
+ context_value.to_s == target_value.to_s
37
+ when 'not_equals'
38
+ context_value.to_s != target_value.to_s
39
+ when 'in'
40
+ target_value.include?(context_value.to_s)
41
+ when 'not_in'
42
+ !target_value.include?(context_value.to_s)
43
+ when 'matches'
44
+ Regexp.new(target_value).match?(context_value.to_s)
45
+ when 'greater_than'
46
+ context_value.to_f > target_value.to_f
47
+ when 'less_than'
48
+ context_value.to_f < target_value.to_f
49
+ else
50
+ false
51
+ end
52
+ end
53
+
54
+ def type_cast_value(value)
55
+ case value_type
56
+ when 'string'
57
+ value.to_s
58
+ when 'integer'
59
+ value.to_i
60
+ when 'float'
61
+ value.to_f
62
+ when 'boolean'
63
+ ActiveModel::Type::Boolean.new.cast(value)
64
+ when 'json'
65
+ value.is_a?(Hash) ? value : JSON.parse(value.to_s)
66
+ end
67
+ end
68
+
69
+ def typed_default_value
70
+ type_cast_value(default_value)
71
+ end
72
+
73
+ def validate_conditions_format
74
+ return if conditions.nil? || conditions.empty?
75
+
76
+ unless conditions.is_a?(Array)
77
+ errors.add(:conditions, "must be an array of condition rules")
78
+ return
79
+ end
80
+
81
+ # Filter out empty conditions and rules
82
+ self.conditions = conditions.reject do |condition|
83
+ condition['return_value'].blank?
84
+ end
85
+
86
+ return if conditions.empty?
87
+
88
+ conditions.each do |condition|
89
+ unless valid_condition_format?(condition)
90
+ errors.add(:conditions, "invalid condition format")
91
+ break
92
+ end
93
+ end
94
+ end
95
+
96
+ def valid_condition_format?(condition)
97
+ return false unless condition.is_a?(Hash)
98
+ return false unless condition['rules'].is_a?(Array)
99
+ return false unless condition['value'].present?
100
+
101
+ condition['rules'].all? do |rule|
102
+ rule.is_a?(Hash) &&
103
+ rule['attribute'].present? &&
104
+ rule['operator'].present? &&
105
+ rule['value'].present?
106
+ end
107
+ end
108
+
109
+ def validate_value_type_consistency
110
+ return if default_value.nil?
111
+
112
+ begin
113
+ typed_default_value
114
+ rescue
115
+ errors.add(:default_value, "is not compatible with value_type")
116
+ end
117
+
118
+ conditions&.each do |condition|
119
+ begin
120
+ type_cast_value(condition['value'])
121
+ rescue
122
+ errors.add(:conditions, "contains values incompatible with value_type")
123
+ break
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,21 @@
1
+ module Wheel
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Wheel
4
+
5
+ config.to_prepare do
6
+ Dir.glob(Engine.root.join("app", "**", "*.rb")).each do |c|
7
+ require_dependency(c)
8
+ end
9
+ end
10
+
11
+ config.generators do |g|
12
+ g.test_framework :rspec
13
+ g.fixture_replacement :factory_bot
14
+ g.factory_bot dir: 'spec/factories'
15
+ end
16
+ end
17
+
18
+ def self.table_name_prefix
19
+ 'wheel_'
20
+ end
21
+ end
@@ -0,0 +1,39 @@
1
+ require 'rails/railtie'
2
+
3
+ module Wheel
4
+ class Railtie < Rails::Railtie
5
+ initializer "wheel.configure" do |app|
6
+ app.config.paths.add 'lib/wheel', eager_load: true
7
+ end
8
+
9
+ config.after_initialize do
10
+ if defined?(Rails::Server) || defined?(Rails::Console)
11
+ begin
12
+ require_relative 'store'
13
+ require_relative 'config'
14
+
15
+ if ActiveRecord::Base.connection.table_exists?('wheel_configs')
16
+ Rails.logger.info "Loading Wheel configurations..."
17
+ Wheel::Store.reload
18
+ end
19
+ rescue => e
20
+ Rails.logger.warn "Failed to initialize Wheel: #{e.message}"
21
+ end
22
+ end
23
+ end
24
+
25
+ # Reload configs in development when files change
26
+ if Rails.env.development?
27
+ config.to_prepare do
28
+ begin
29
+ if defined?(Wheel::Store) && ActiveRecord::Base.connection.table_exists?('wheel_configs')
30
+ Rails.logger.debug "Reloading Wheel configurations..."
31
+ Wheel::Store.reload
32
+ end
33
+ rescue => e
34
+ Rails.logger.warn "Failed to reload Wheel: #{e.message}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,78 @@
1
+ require 'active_support/core_ext/module/delegation'
2
+ require 'active_record'
3
+ require 'wheel/engine'
4
+
5
+ module Wheel
6
+ class Store
7
+ class << self
8
+ delegate :reload, :get, :[]=, to: :instance
9
+
10
+ def instance
11
+ @instance ||= new
12
+ end
13
+
14
+ def method_missing(method_name, *args, &block)
15
+ instance.public_send(method_name, *args, &block)
16
+ end
17
+
18
+ def respond_to_missing?(method_name, include_private = false)
19
+ instance.respond_to?(method_name, include_private)
20
+ end
21
+ end
22
+
23
+ def initialize
24
+ reload
25
+ end
26
+
27
+ def reload
28
+ @configs = {}
29
+ if defined?(ActiveRecord) && ActiveRecord::Base.connected? &&
30
+ ActiveRecord::Base.connection.table_exists?('wheel_configs')
31
+ Wheel::Config.find_each do |config|
32
+ @configs[config.key] = config
33
+ end
34
+ end
35
+ rescue => e
36
+ Rails.logger.warn "Failed to load Wheel configs: #{e.message}" if defined?(Rails)
37
+ @configs
38
+ end
39
+
40
+ def [](key, context = {})
41
+ config = @configs[key.to_s]
42
+ return nil unless config
43
+
44
+ config.evaluate(context)
45
+ end
46
+
47
+ def []=(key, value)
48
+ config = Wheel::Config.find_or_initialize_by(key: key)
49
+ config.update!(
50
+ value_type: detect_value_type(value),
51
+ default_value: value
52
+ )
53
+ @configs[key.to_s] = config
54
+ value
55
+ end
56
+
57
+ private
58
+
59
+ def detect_value_type(value)
60
+ case value
61
+ when String
62
+ 'string'
63
+ when Integer
64
+ 'integer'
65
+ when Float
66
+ 'float'
67
+ when TrueClass, FalseClass
68
+ 'boolean'
69
+ when Array
70
+ 'array'
71
+ when Hash
72
+ 'json'
73
+ else
74
+ 'string'
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,3 @@
1
+ module Wheel
2
+ VERSION = "0.1.1"
3
+ end
data/lib/wheel.rb ADDED
@@ -0,0 +1,42 @@
1
+ require "wheel/version"
2
+
3
+ module Wheel
4
+ class Error < StandardError; end
5
+
6
+ autoload :Store, 'wheel/store'
7
+ autoload :Config, 'wheel/config'
8
+
9
+ class << self
10
+ def [](key, context = {})
11
+ Store[key, context]
12
+ end
13
+
14
+ def reload
15
+ Store.reload
16
+ end
17
+
18
+ def []=(key, value)
19
+ Store[key] = value
20
+ end
21
+
22
+ def configure
23
+ yield(self) if block_given?
24
+ end
25
+
26
+ def attributes=(attrs)
27
+ @attributes = attrs
28
+ end
29
+
30
+ def attributes
31
+ @attributes ||= {
32
+ user_id: {
33
+ name: 'User ID',
34
+ description: 'The unique identifier of the user'
35
+ }
36
+ }
37
+ end
38
+ end
39
+ end
40
+
41
+ require "wheel/engine" if defined?(Rails)
42
+ require "wheel/railtie" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wheel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Abdullah Almanie
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 6.0.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: byebug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: A Rails engine that provides feature flag and configuration management
70
+ email:
71
+ - ruby@almanie.dev
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - README.md
77
+ - app/controllers/wheel/configs_controller.rb
78
+ - app/models/wheel/application_record.rb
79
+ - app/views/layouts/wheel/application.html.erb
80
+ - app/views/wheel/configs/_form.html.erb
81
+ - app/views/wheel/configs/edit.html.erb
82
+ - app/views/wheel/configs/index.html.erb
83
+ - app/views/wheel/configs/new.html.erb
84
+ - config/routes.rb
85
+ - lib/generators/wheel/install/install_generator.rb
86
+ - lib/generators/wheel/install/templates/create_wheel_configs.rb
87
+ - lib/generators/wheel/install/templates/wheel.rb
88
+ - lib/wheel.rb
89
+ - lib/wheel/application_record.rb
90
+ - lib/wheel/config.rb
91
+ - lib/wheel/engine.rb
92
+ - lib/wheel/railtie.rb
93
+ - lib/wheel/store.rb
94
+ - lib/wheel/version.rb
95
+ homepage: https://github.com/Abdullah-l/wheel
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/Abdullah-l/wheel
100
+ source_code_uri: https://github.com/Abdullah-l/wheel
101
+ post_install_message:
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.5.11
117
+ signing_key:
118
+ specification_version: 4
119
+ summary: Feature flag and configuration management system for Rails
120
+ test_files: []