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 +7 -0
- data/README.md +94 -0
- data/app/controllers/wheel/configs_controller.rb +65 -0
- data/app/models/wheel/application_record.rb +5 -0
- data/app/views/layouts/wheel/application.html.erb +105 -0
- data/app/views/wheel/configs/_form.html.erb +314 -0
- data/app/views/wheel/configs/edit.html.erb +9 -0
- data/app/views/wheel/configs/index.html.erb +68 -0
- data/app/views/wheel/configs/new.html.erb +8 -0
- data/config/routes.rb +4 -0
- data/lib/generators/wheel/install/install_generator.rb +35 -0
- data/lib/generators/wheel/install/templates/create_wheel_configs.rb +17 -0
- data/lib/generators/wheel/install/templates/wheel.rb +25 -0
- data/lib/wheel/application_record.rb +5 -0
- data/lib/wheel/config.rb +128 -0
- data/lib/wheel/engine.rb +21 -0
- data/lib/wheel/railtie.rb +39 -0
- data/lib/wheel/store.rb +78 -0
- data/lib/wheel/version.rb +3 -0
- data/lib/wheel.rb +42 -0
- metadata +120 -0
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,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,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
|
data/lib/wheel/config.rb
ADDED
|
@@ -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
|
data/lib/wheel/engine.rb
ADDED
|
@@ -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
|
data/lib/wheel/store.rb
ADDED
|
@@ -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
|
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: []
|