flipside 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e49af6fc8d31128f0e88089ad6d46ce1745a223a68c4f5e4f2e5353f99091073
4
- data.tar.gz: cb1547b1c3892b363830aa5f75375998092b90f150e6a275b3224051b6eb3b19
3
+ metadata.gz: d4d7b79a7cff6585a0858dfeb93ca2a79511458ae211bd27e5cf70c5f114ff79
4
+ data.tar.gz: b526c4c4eee41a72f6576d8fb3044815b3d58aa515a38280d754d504e4d55b15
5
5
  SHA512:
6
- metadata.gz: cc95be97c95f5c6285d4eb57e6b378b47277d61e43f6606eb44bf9c9c6f914a2b6019c4a143c8b9a5058c64eac6ea0f4cffbecf7138ad3836ca0dcb196515c22
7
- data.tar.gz: fb5262d598dea36dd87e7c987ae1d263500b2bc7e39882d7da5a119a6c11aab009821d7981732a442176985054900355e6bcf2b8d2b0fa68eab99e03a6469337
6
+ metadata.gz: 63a30c74eb4b138808e24b79316806325b47a42dabc17fa9d59550e2967edd8a106b85c80612bc2dfa7549a61f329ff2952331f25034bde125c3c9912d99db45
7
+ data.tar.gz: fbd4ac70e3e00e6935201925f51e7eb7bce04f8c4735267e4bba8e919d2f1214818bee38c2829691bbf7981543f28f0469379c47df8b63b3a7faca157e3150d0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [0.3.0] - 2025-05-14
2
+ - Rewrite Sinatra UI to Roda
3
+ - Fix links not respecting mount point
4
+ - Add Cache-Control header for js files
5
+
6
+ ## [0.2.2] - 2025-04-25
7
+
8
+ - Add disabled? method
9
+ - Edit feature description from UI
10
+ - Add configuration for setting default_object
11
+
1
12
  ## [0.2.1] - 2025-01-30
2
13
 
3
14
  - Option to create missing features
data/README.md CHANGED
@@ -33,41 +33,72 @@ This will create a migration file. Run the migration, to add the flipside tables
33
33
 
34
34
  ## Usage
35
35
 
36
- 1. Defining Features
36
+ ### Defining Features
37
37
 
38
38
  Features are created by running this (in a console or from code):
39
39
  ```ruby
40
- Flipside::Feature.create(
40
+ Flipside.create(
41
41
  name: "MyFeature",
42
42
  description: "Some optional description about what this feature do"
43
43
  )
44
44
  ```
45
45
 
46
- By default features are turned off. If you would like it turned on from the beginning you could pass in `enabled: true`.
46
+ By default features are turned off. If we would like it turned on from the get go we could pass in `enabled: true`.
47
47
  ```ruby
48
- Flipside::Feature.create(name: "MyFeature", enabled: true)
48
+ Flipside.create(name: "MyFeature", enabled: true)
49
49
  ```
50
50
 
51
51
  Features can be active during a given period. Set `activated_at` and/or `deactivated_at` to define this period.
52
52
  Note: A feature is always disabled outside of the active period.
53
+ Note: A nil value means that its active. I.e. `activated_at = nil` means active from the start, `deactivated_at = nil` means never deactivates.
53
54
  ```ruby
54
- Flipside::Feature.create(
55
+ Flipside.create(
55
56
  name: "MyFeature",
56
57
  activated_at: 1.week.from_now,
57
58
  deactivated_at: 2.weeks.from_now
58
59
  )
59
60
  ```
60
61
 
61
- Features can be enabled for a certain record, typically a certain user or organization. This records are called entities. To enable a feature for a given record use `.add_entity`:
62
+ ### Checking Feature Status
63
+
64
+ #### Globally
65
+
66
+ Check if a feature is enabled globally:
67
+
68
+ ```ruby
69
+ Flipside.enabled? "MyFeature"
70
+ ```
71
+
72
+ We can also check if a feature is disabled:
73
+ ```ruby
74
+ Flipside.disabled? "MyFeature"
75
+ ```
76
+
77
+ #### For a Specific Record
78
+
79
+ Check if a feature is enabled for a specific record (e.g. a user):
80
+
81
+ ```ruby
82
+ Flipside.enabled? "MyFeature", user
83
+ ```
84
+
85
+ We can also check multiple records. `.enabled?` will return `true` if any of them have the feature enabled:
86
+ ```ruby
87
+ Flipside.enabled? "MyFeature", user, company, location
88
+ ```
89
+
90
+ ### Enabling features for specific records
91
+
92
+ Features can be enabled for a certain record, typically a certain user or organization. These records are called entities. To enable a feature for a given record use `.add_entity`:
62
93
  ```ruby
63
94
  user = User.first
64
- Flipside.enabled? user # => false
95
+ Flipside.enabled? "MyFeature", user # => false
65
96
  Flipside.add_entity(name: "MyFeature", user)
66
- Flipside.enabled? user # => true
97
+ Flipside.enabled? "MyFeature", user # => true
67
98
  ```
68
99
 
69
100
  Features can be enabled for records responding true to a certain method. This is called a "role". Given that User records have an admin? method. A feature can then be enabled
70
- for all users how are admins, using the `.add_role` method:
101
+ for all users who are admins, using the `.add_role` method:
71
102
  ```ruby
72
103
  user1 = User.new(admin: false)
73
104
  user2 = User.new(admin: true)
@@ -76,43 +107,69 @@ Flipside.add_role(
76
107
  class_name: "User",
77
108
  method_name: :admin?
78
109
  )
79
- Flipside.enabled? user1 # => false
80
- Flipside.enabled? user2 # => true
110
+ Flipside.enabled? "MyFeature", user1 # => false
111
+ Flipside.enabled? "MyFeature", user2 # => true
112
+ Flipside.enabled? "MyFeature", user1, user2 # => true, enabled for at least one.
81
113
  ```
82
114
 
83
115
 
84
- 2. Checking Feature Status
116
+ ## UI
117
+ Flipside comes with a Roda web ui to mange feature flags. To mount this roda app in Rails add the following to your routes.rb file.
118
+ ```ruby
119
+ mount Flipside::Web, at: '/flipside'
120
+ ```
121
+ Note: you probably want to wrap this inside a constraints block to provide some authentication.
85
122
 
86
- #### Globally
123
+ ![UI](/features.png)
87
124
 
88
- Check if a feature is enabled globally:
89
125
 
90
- ```ruby
91
- Flipside.enabled? "MyFeature"
92
- ```
126
+ ### Configuration
93
127
 
94
- #### For a Specific Record
128
+ The Flipside UI can be configured by calling some class methods on `Flipside` (see below).
95
129
 
96
- Check if a feature is enabled for a specific record (e.g. a user):
130
+ `ui_back_path` is used to set a path to return to from the Flipside UI. If this is set, then the UI shows a "Back" button,
131
+ targeting this path/url. By default no back button is shown.
132
+
133
+
134
+ If `create_missing_features` is set to true, then features will automatically be created, whenever a check for an unknown feature is done.
135
+ This can be a convenient way of makes features "show up" in the Flipside UI. However, the description will not reveal much about what this features does.
136
+ (it will simply point to the place in the code where this check was done). So these features should be manually updated with a better description.
137
+ By default, features are not added and code like `Flipside.enabled? "Some unknown feature"` will simply return `false`.
97
138
 
139
+ `default_object` can be used to avoid the need to always pass in a record when checking if a feature is enabled. For example, say that we
140
+ always want to check if a feature is enabled for the currently logged in user (`Current.user`). Then we might add this configuration:
98
141
  ```ruby
99
- Flipside.enabled? "MyFeature", user
142
+ Flipside.default_object = -> { Current.user }
100
143
  ```
101
-
102
- ## UI
103
- Flipside comes with a sinatra web ui to mange feature flags. To mount this sinatra app in Rails add the following to your routes.rb file.
144
+ Now we can check if a feature is enabled without needing to pass in `Current.user`:
104
145
  ```ruby
105
- mount Flipside::Web, at: '/flipside'
146
+ # With `default_object` set then all we need is this
147
+ Flipside.enabled? :some_feature
148
+
149
+ # Which will be the same as
150
+ Flipside.enabled? :some_feature, Current.user
151
+
152
+ # Note: if we do pass in an argument, then `default_object ` will not be used:
153
+ Flipside.enabled? :some_feature, Current.company # check current company instead of user.
106
154
  ```
107
- Note: you probably want to wrap this inside a constraints block to provide some authentication.
108
155
 
109
- ![UI](/features.png)
110
156
 
157
+ Typically this configuration should be declared in an initializer file.
111
158
 
112
- ### Configuration
159
+ ```ruby
160
+ # config/initializers/flipside.rb
161
+ require 'flipside'
113
162
 
114
- Entities can be added to a feature by searching for records and adding them.
115
- ![Start screen](/add_entity.png)
163
+ Flipside.ui_back_path = "/"
164
+ Flipside.create_missing_features = true
165
+ Flipside.default_object = -> { Current.user }
166
+ ```
167
+
168
+ #### Entities
169
+
170
+ Entities can be added to a feature by searching for records.
171
+
172
+ ![Add an entity](/add_entity.png)
116
173
 
117
174
  To make this work, some configuration is required. Use the class method `Flipside.register_entity` for this.
118
175
  ```ruby
@@ -123,12 +180,59 @@ Flipside.register_entity(
123
180
  identified_by: :id
124
181
  )
125
182
  ```
126
- Typically this should be configured in an initializer file.
127
183
 
128
- The `.register_entity` method should be called once for each class that may be used as feature enablers.
129
- The `search_by` keyword argument, which may be a symbol or a proc, dictates how records are found from searching in the ui. When a symbol is given, then searches with an exact match on the corresponding attribute are returned. E.g. `User.where(name: query)`.
184
+ The `.register_entity` method should be called once for each class that may be used as a feature enabler.
185
+ The `search_by` keyword argument, which may be a `Symbol` or a `Proc`, dictates how records are found from searching in the ui.
186
+ When a `Symbol` is given, e.g. `:name`, then entities with an exact match on the corresponding attribute are returned. I.e. `User.where(name: query)`.
187
+ When a `Proc` is given, then this `Proc` is called with the search string and is expected to return an object responding to `to_a` (e.g. an AR collection).
188
+ This gives us the flexibility to decide how to search for entities. For example, to search for users with matching first name or last name or an email
189
+ starting with _query_, something like this could be used.
190
+ ```ruby
191
+ Flipside.register_entity(
192
+ class_name: "User",
193
+ search_by: ->(str) { User.where("lower(first_name) = :name OR lower(last_name) = :name or email LIKE :str", name: str.downcase, str: "#{str}%") },
194
+ )
195
+
196
+ ```
197
+
198
+ The `identified_by` keyword argument, sets the column used as primary key for the corresponding table. This defaults to `:id` and typically does need to be change.
199
+ Currently composite keys are not supported.
200
+
201
+ The `display_as` keyword argument, is used to configure how these entities show up in the combobox. When set to a `Symbol`, then this value is sent to the corresponding entity.
202
+ For example, given the following setup. Users will be displayed with first name and last name:
203
+ ```ruby
204
+ class User < ApplicationRecord
205
+ def name
206
+ [first_name, last_name].compact.map(&:capitalize).join(" ")
207
+ end
208
+ end
209
+
210
+ Flipside.register_entity(
211
+ class_name: "User",
212
+ display_as: :name,
213
+ )
214
+ ```
215
+
216
+ When a `Proc` is given, then it is expected to take an entity as input and return a string used for displaying the entity. The config above could then instead be done using:
217
+ ```ruby
218
+ Flipside.register_entity(
219
+ class_name: "User",
220
+ display_as: ->(user) { [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") }
221
+ )
222
+ ```
130
223
 
131
- TODO
224
+ #### Roles
225
+
226
+ Features can be enabled for certain roles, by searching for roles (by method name).
227
+
228
+ ![Add a role](/add_role.png)
229
+
230
+ This is configured by calling the class method `Flipside.register_role` for each role to be added.
231
+ Note a role consists of a class and a corresponding instance method.
232
+ ```ruby
233
+ Flipside.register_role(class_name: "User", method_name: :admin?)
234
+ Flipside.register_role(class_name: "User", method_name: :awesome?)
235
+ ```
132
236
 
133
237
  ## Development
134
238
 
@@ -2,6 +2,16 @@ module Flipside
2
2
  module Config
3
3
  module Settings
4
4
  attr_accessor :ui_back_path, :create_missing_features
5
+ attr_writer :default_object
6
+
7
+ def default_object
8
+ case @default_object
9
+ when Proc
10
+ @default_object.call
11
+ else
12
+ @default_object
13
+ end
14
+ end
5
15
  end
6
16
  end
7
17
  end
@@ -2,49 +2,12 @@ require 'forwardable'
2
2
 
3
3
  class FeaturePresenter
4
4
  extend Forwardable
5
- attr_reader :feature, :base_path
5
+ attr_reader :feature
6
6
 
7
7
  def_delegators :@feature, :name, :description, :enabled, :entities, :roles
8
8
 
9
- def initialize(feature, base_path)
9
+ def initialize(feature)
10
10
  @feature = feature
11
- @base_path = base_path
12
- end
13
-
14
- def href
15
- File.join(base_path, "feature", ERB::Util.url_encode(name))
16
- end
17
-
18
- def toggle_path
19
- File.join(href, "toggle")
20
- end
21
-
22
- def back_path
23
- base_path
24
- end
25
-
26
- def entities_path
27
- File.join(href, "entities")
28
- end
29
-
30
- def add_entity_path
31
- File.join(href, "add_entity")
32
- end
33
-
34
- def remove_entity_path
35
- File.join(href, "remove_entity")
36
- end
37
-
38
- def roles_path
39
- File.join(href, "roles")
40
- end
41
-
42
- def add_role_path
43
- File.join(href, "add_role")
44
- end
45
-
46
- def remove_role_path
47
- File.join(href, "remove_role")
48
11
  end
49
12
 
50
13
  def status
@@ -0,0 +1,26 @@
1
+ module Flipside
2
+ module Importmap
3
+ LIBRARIES = {
4
+ "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus/dist/stimulus.js",
5
+ }.freeze
6
+
7
+ def importmap_tags
8
+ JSON.generate({imports:})
9
+ end
10
+
11
+ private
12
+
13
+ def imports
14
+ LIBRARIES.merge(controllers)
15
+ end
16
+
17
+ def controllers
18
+ pattern = File.join(__dir__, "public/*_controller.js")
19
+
20
+ Dir.glob(pattern).to_h do |controller|
21
+ name = File.basename(controller, ".js")
22
+ [name, public_path("#{name}.js")]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,9 +1,9 @@
1
1
  import { Application } from "@hotwired/stimulus"
2
- import ToggleController from "toggle_controller"
3
2
  import SearchController from "search_controller"
4
3
  import ModalController from "modal_controller"
4
+ import InlineEditController from "inline_edit_controller"
5
5
 
6
6
  const application = Application.start()
7
- application.register("toggle", ToggleController)
8
7
  application.register("search", SearchController)
9
8
  application.register("modal", ModalController)
9
+ application.register("inline-edit", InlineEditController)
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class InlineEditController extends Controller {
4
+ static targets = ["read", "edit"];
5
+
6
+ edit() {
7
+ this.readTargets.forEach(element => {
8
+ element.classList.add("hidden")
9
+ })
10
+
11
+ this.editTargets.forEach(element => {
12
+ element.classList.remove("hidden")
13
+ })
14
+ }
15
+
16
+ cancel() {
17
+ this.editTargets.forEach(element => {
18
+ element.classList.add("hidden")
19
+ })
20
+ this.readTargets.forEach(element => {
21
+ element.classList.remove("hidden")
22
+ })
23
+ }
24
+ }
25
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipside
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -16,7 +16,7 @@
16
16
  </button>
17
17
  </div>
18
18
  <div class="mt-4">
19
- <form action="<%= feature.href %>" method="post">
19
+ <form action="<%= feature_path(feature) %>" method="post">
20
20
  <input type="hidden" name="_method" value="put" />
21
21
  <%# <input type="hidden" name="feature_name" value="<%= feature.name %1>" /> %>
22
22
  <label class="font-bold text-slate-800">Update <%= attribute %></label>
@@ -1,6 +1,10 @@
1
- <div class="p-4 grid grid-cols-12 gap-4 cursor-pointer text-slate-300">
2
- <div class="col-span-3 text-left text-xl"><%= feature.name %></div>
3
- <div class="col-span-7 text-center text-lg"><%= feature.description&.capitalize %></div>
1
+ <div class="p-4 grid grid-cols-12 gap-4 items-center text-slate-300">
2
+ <div class="col-span-10">
3
+ <a href=<%= feature_path(feature) %> class="grid grid-cols-9 cursor-pointer hover:text-slate-400">
4
+ <div class="col-span-3 text-left text-xl"><%= feature.name %></div>
5
+ <div class="col-span-6 text-center text-lg"><%= feature.description&.capitalize %></div>
6
+ </a>
7
+ </div>
4
8
  <div class="col-span-1 text-right text-lg font-normal">
5
9
  <span
6
10
  class="inline-block py-1 px-2 min-w-20 text-center rounded-lg <%= feature.status_color %>"
@@ -9,6 +13,6 @@
9
13
  </span>
10
14
  </div>
11
15
  <div class="text-right">
12
- <%= erb :_toggle_button, locals: {feature:} %>
16
+ <%= render :_toggle_button, locals: {feature:} %>
13
17
  </div>
14
18
  </div>
@@ -1,12 +1,12 @@
1
1
  <% color = feature.enabled ? "bg-blue-700" : "bg-gray-200" %>
2
2
  <% translate = feature.enabled ? "translate-x-5" : "translate-x-0" %>
3
+ <%# FIXME: How to prevent clicking the submit button from propageting? E.g. link navigation on index page. %>
3
4
 
4
- <button type="button"
5
- class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent <%= color %>"
6
- data-controller="toggle"
7
- data-action="click->toggle#switch:prevent"
8
- data-toggle-url-value="<%= feature.toggle_path %>"
9
- data-toggle-enabled-value="<%= feature.enabled %>"
10
- >
11
- <span aria-hidden="true" class="pointer-events-none inline-block size-5 <%= translate %> transform rounded-full bg-white shadow"></span>
12
- </button>
5
+ <form action="<%= feature_path(feature, "toggle") %>" method="post">
6
+ <input type="hidden" name="_method" value="put" />
7
+ <button type="submit"
8
+ class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent <%= color %>"
9
+ >
10
+ <span aria-hidden="true" class="pointer-events-none inline-block size-5 <%= translate %> transform rounded-full bg-white shadow"></span>
11
+ </button>
12
+ </form>
@@ -1,10 +1,10 @@
1
1
  <div class="m-12">
2
- <a href="<%= feature.href %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
2
+ <a href="<%= feature_path(feature) %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
3
  <div class="mt-12 w-1/3 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
4
  <h1 class="text-2xl font-bold mb-6">Entities for <span class="font-extrabold text-slate-300"><%= feature.name %></span></h1>
5
5
  <h2 class="text-xl font-bold">Add entity</h2>
6
- <form action="<%= feature.add_entity_path %>" method="post">
7
- <div data-controller="search" data-search-url-value="/flipside/search_entity" class="w-full flex p-0 gap-2 items-center">
6
+ <form action="<%= feature_path(feature, "add_entity") %>" method="post">
7
+ <div data-controller="search" data-search-url-value="<%= search_entity_path %>" class="w-full flex p-0 gap-2 items-center">
8
8
  <select
9
9
  name="class_name"
10
10
  data-search-target="param"
@@ -54,7 +54,7 @@
54
54
  <%= Flipside.display_entity(entity.flippable) %> (<%= entity.flippable_type %>)
55
55
  </div>
56
56
  <div>
57
- <form action="<%= feature.remove_entity_path %>" method="post">
57
+ <form action="<%= feature_path(feature, "remove_entity") %>" method="post">
58
58
  <input type="hidden" name="entity_id" value="<%= entity.id %>" />
59
59
  <button type="submit" class="p-2 bg-gray-300 text-gray-700 font-semibold rounded hover:bg-gray-400">Remove</button>
60
60
  </form>
@@ -1,10 +1,10 @@
1
1
  <div class="m-12">
2
- <a href="<%= feature.href %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
2
+ <a href="<%= feature_path(feature) %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
3
  <div class="mt-12 w-1/3 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
4
  <h1 class="text-2xl font-bold mb-6">Roles for <span class="font-extrabold text-slate-300"><%= feature.name %></span></h1>
5
5
  <h2 class="text-xl font-bold">Add Role</h2>
6
- <form action="<%= feature.add_role_path %>" method="post">
7
- <div data-controller="search" data-search-url-value="/flipside/search_role" class="w-full flex p-0 gap-2 items-center">
6
+ <form action="<%= feature_path(feature, "add_role") %>" method="post">
7
+ <div data-controller="search" data-search-url-value="<%= search_role_path %>" class="w-full flex p-0 gap-2 items-center">
8
8
  <select
9
9
  name="class_name"
10
10
  data-search-target="param"
@@ -54,7 +54,7 @@
54
54
  <%= "#{role.class_name}##{role.method}" %>
55
55
  </div>
56
56
  <div>
57
- <form action="<%= feature.remove_role_path %>" method="post">
57
+ <form action="<%= feature_path(feature, "remove_role") %>" method="post">
58
58
  <input type="hidden" name="role_id" value="<%= role.id %>" />
59
59
  <button type="submit" class="p-2 bg-gray-300 text-gray-700 font-semibold rounded hover:bg-gray-400">Remove</button>
60
60
  </form>
@@ -7,11 +7,13 @@
7
7
 
8
8
  <div class="mt-12 m-auto">
9
9
  <ul>
10
+ <li class="hidden only:block text-center">
11
+ <p>No feature added yet!</p><p>Create a new feature from the console by typing:</p>
12
+ <code class="inline-block p-1 text-green-700 bg-slate-900">Flipside.create(name: "my_feature", description: "Some decription...")</code>
13
+ </li>
10
14
  <% features.each do |feature| %>
11
- <li class="border-b border-slate-500 hover:font-semibold">
12
- <a href=<%= feature.href %>>
13
- <%= erb :_feature_item, locals: {feature:} %>
14
- </a>
15
+ <li class="border-b border-slate-500">
16
+ <%= render :_feature_item, locals: {feature:} %>
15
17
  </li>
16
18
  <% end %>
17
19
  </ul>
@@ -5,14 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <script src="https://cdn.tailwindcss.com"></script>
7
7
  <script type="importmap">
8
- {
9
- "imports": {
10
- "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus/dist/stimulus.js",
11
- "toggle_controller": "/flipside/toggle_controller.js",
12
- "search_controller": "/flipside/search_controller.js",
13
- "modal_controller": "/flipside/modal_controller.js"
14
- }
15
- }
8
+ <%= importmap_tags %>
16
9
  </script>
17
10
  </head>
18
11
  <body class="w-full h-full bg-slate-600">
@@ -20,6 +13,6 @@
20
13
  <%= yield %>
21
14
  </div>
22
15
 
23
- <script src="/flipside/index.js" type="module" ></script>
16
+ <script src="<%= public_path("index.js") %>" type="module" ></script>
24
17
  </body>
25
18
  </html>
@@ -1,5 +1,5 @@
1
1
  <div class="m-12">
2
- <a href="<%= feature.back_path %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
2
+ <a href="<%= base_path %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
3
  <div class="mt-12 w-1/2 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
4
  <h1 class="text-2xl font-bold mb-6">Feature Details</h1>
5
5
  <div class="mt-4 grid grid-cols-1 gap-4">
@@ -8,25 +8,47 @@
8
8
  <span class="font-semibold">Name:</span>
9
9
  <span class="font-bold text-lg text-slate-300"><%= feature.name %></span>
10
10
  </div>
11
- <div class="mt-2 flex justify-between items-center">
12
- <span class="font-semibold">Description:</span>
13
- <span><%= feature.description %></span>
11
+ <div class="mt-2 flex justify-between items-center text-center" data-controller="inline-edit">
12
+ <div class="font-semibold">Description:</div>
13
+ <div data-inline-edit-target="read"><%= feature.description %></div>
14
+ <div class="ml-2 " data-inline-edit-target="read">
15
+ <button
16
+ class="px-2 py-1 rounded-lg bg-blue-900 text-xl text-slate-400 border border-blue-950 hover:bg-blue-800"
17
+ data-action="inline-edit#edit" >
18
+ Edit
19
+ </button>
20
+ </div>
21
+ <form action="<%= feature_path(feature) %>" method="post" class="hidden ml-4 grow flex gap-2" data-inline-edit-target="edit">
22
+ <input type="hidden" name="_method" value="put" />
23
+ <input
24
+ type="text"
25
+ value="<%= feature.description %>"
26
+ name="description"
27
+ class="grow border text-slate-600 border-slate-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
28
+ <button
29
+ type="button"
30
+ class="p-2 bg-slate-600 text-slate-300 font-semibold rounded hover:bg-slate-500"
31
+ data-action="inline-edit#cancel" >
32
+ Cancel
33
+ </button>
34
+ <button type="submit" class="p-2 bg-blue-900 font-semibold rounded hover:bg-blue-800">Update</button>
35
+ </form>
14
36
  </div>
15
37
  </div>
16
38
  <div class="mt-2 flex justify-between items-center">
17
39
  <span class="font-semibold">Enabled for all:</span>
18
- <span><%= erb :_toggle_button, locals: {feature:} %></span>
40
+ <span><%= render :_toggle_button, locals: {feature:} %></span>
19
41
  </div>
20
42
  <div class="mt-2 flex justify-between items-center">
21
43
  <span class="font-semibold">Active From:</span>
22
44
  <span>
23
- <%= erb :_datetime_modal, locals: {feature:, attribute: :activated_at} %>
45
+ <%= render :_datetime_modal, locals: {feature:, attribute: :activated_at} %>
24
46
  </span>
25
47
  </div>
26
48
  <div class="mt-2 flex justify-between items-center">
27
49
  <span class="font-semibold">Active Until:</span>
28
50
  <span>
29
- <%= erb :_datetime_modal, locals: {feature:, attribute: :deactivated_at} %>
51
+ <%= render :_datetime_modal, locals: {feature:, attribute: :deactivated_at} %>
30
52
  </span>
31
53
  </div>
32
54
  <div class="mt-2 grid grid-cols-3 items-center">
@@ -34,17 +56,18 @@
34
56
  <span class="text-center"><%= feature.entity_count_str %></span>
35
57
  <span class="text-right text-xl text-slate-400">
36
58
  <a
37
- href="<%= feature.entities_path %>"
59
+ href="<%= feature_path(feature, "entities") %>"
38
60
  class="px-2 py-1 rounded-lg bg-blue-900 border border-blue-950 hover:bg-blue-800" >
39
61
  Edit
40
62
  </a>
41
63
  </span>
42
- </div><div class="mt-2 grid grid-cols-3 items-center">
64
+ </div>
65
+ <div class="mt-2 grid grid-cols-3 items-center">
43
66
  <span class="font-semibold">Roles:</span>
44
67
  <span class="text-center"><%= feature.role_count_str %></span>
45
68
  <span class="text-right text-xl text-slate-400">
46
69
  <a
47
- href="<%= feature.roles_path %>"
70
+ href="<%= feature_path(feature, "roles") %>"
48
71
  class="px-2 py-1 rounded-lg bg-blue-900 border border-blue-950 hover:bg-blue-800" >
49
72
  Edit
50
73
  </a>
data/lib/flipside/web.rb CHANGED
@@ -1,99 +1,125 @@
1
- require "sinatra"
1
+ require 'roda'
2
2
  require "uri"
3
3
  require "flipside/feature_presenter"
4
+ require "flipside/importmap"
5
+ require "rack/method_override"
4
6
 
5
7
  module Flipside
6
- class Web < Sinatra::Base
7
- not_found do
8
- erb :not_found
8
+ class Web < Roda
9
+ include Flipside::Importmap
10
+ use Rack::MethodOverride
11
+
12
+ opts[:add_script_name] = true
13
+
14
+ plugin :r
15
+ plugin :json
16
+ plugin :halt
17
+ plugin :path
18
+ plugin :all_verbs
19
+ plugin :unescape_path
20
+ plugin :render, views: File.expand_path("views", __dir__)
21
+ plugin :public,
22
+ root: File.expand_path("public", __dir__),
23
+ headers: {"Cache-Control" => "public, max-age=14400"}
24
+
25
+ path(:public) { |name| "/#{name}" }
26
+ path(:base, "/")
27
+ path(:feature) do |feature, *segments, **query|
28
+ path = "/feature/#{ERB::Util.url_encode(feature.name)}"
29
+ path = "#{path}/#{segments.join("/")}" if segments.any?
30
+ path = "#{path}?#{query.map { |k,v| "#{k}=#{v}" }.join("&")}" if query.keys.any?
31
+ path
9
32
  end
33
+ path(:search_entity, "/search_entity")
34
+ path(:search_role, "/search_role")
10
35
 
11
- get "/" do
12
- features = Flipside::Feature.order(:name).map do |feature|
13
- FeaturePresenter.new(feature, base_path)
14
- end
15
-
16
- erb :index, locals: {features:}
36
+ def load_feature(name)
37
+ Flipside::Feature.find_by!(name:)
38
+ rescue
39
+ r.halt 404, view(:not_found)
17
40
  end
18
41
 
19
- get "/feature/:name" do
20
- erb :show, locals: {feature: FeaturePresenter.new(feature, base_path)}
21
- end
42
+ route do |r|
43
+ r.public
22
44
 
23
- put "/feature/:name/toggle" do
24
- content_type :text
45
+ r.root do
46
+ features = Flipside::Feature.order(:name).map do |feature|
47
+ FeaturePresenter.new(feature)
48
+ end
25
49
 
26
- if feature.update(enabled: !feature.enabled)
27
- 204
28
- else
29
- [422, "Failed to update feature"]
50
+ view :index, locals: {features:}
30
51
  end
31
- end
32
-
33
- put "/feature/:name" do
34
- kwargs = params.slice("activated_at", "deactivated_at")
35
- feature.update(**kwargs)
36
- redirect to("/feature/#{params["name"]}"), 303
37
- end
38
-
39
- get "/feature/:name/entities" do
40
- erb :feature_entities, locals: {feature: FeaturePresenter.new(feature, base_path)}
41
- end
42
-
43
- get '/search_entity' do
44
- class_name = params[:class_name]
45
- query = URI.decode_www_form_component(params[:q])
46
- result = Flipside.search_entity(class_name:, query:)
47
-
48
- erb :_search_result, locals: {result:, class_name:, query:}
49
- end
50
-
51
- post "/feature/:name/add_entity" do
52
- name, class_name, identifier = params.values_at("name", "class_name", "identifier")
53
-
54
- entity = Flipside.find_entity(class_name:, identifier:)
55
- Flipside.add_entity(name: , entity:)
56
- redirect to("/feature/#{name}/entities"), 303
57
- end
58
-
59
- post "/feature/:name/remove_entity" do
60
- Flipside.remove_entity(name: params["name"], entity_id: params["entity_id"])
61
- redirect to("/feature/#{params["name"]}/entities"), 303
62
- end
63
-
64
- get "/feature/:name/roles" do
65
- erb :feature_roles, locals: {feature: FeaturePresenter.new(feature, base_path)}
66
- end
67
-
68
- get '/search_role' do
69
- class_name = params[:class_name]
70
- query = URI.decode_www_form_component(params[:q])
71
- result = Flipside.search_role(class_name:, query:)
72
-
73
- erb :_search_result, locals: {result:, class_name:, query:}
74
- end
75
-
76
- post "/feature/:name/add_role" do
77
- name, class_name, method_name = params.values_at("name", "class_name", "identifier")
78
- Flipside.add_role(name:, class_name:, method_name:)
79
-
80
- redirect to("/feature/#{name}/roles"), 303
81
- end
82
-
83
- post "/feature/:name/remove_role" do
84
- Flipside.remove_role(name: params["name"], role_id: params["role_id"])
85
52
 
86
- redirect to("/feature/#{params["name"]}/roles"), 303
87
- end
53
+ r.on "feature", String do |name|
54
+ feature = load_feature(name)
55
+
56
+ r.is do
57
+ r.get do
58
+ view(:show, locals: { feature: FeaturePresenter.new(feature) })
59
+ end
60
+
61
+ r.put do
62
+ kwargs = r.params.slice("description", "activated_at", "deactivated_at")
63
+ feature.update(**kwargs)
64
+ r.redirect r.path, 303
65
+ end
66
+ end
67
+
68
+ r.put "toggle" do
69
+ if feature.update(enabled: !feature.enabled)
70
+ referer = r.env["HTTP_REFERER"]
71
+ r.redirect (referer || feature_path(feature)), 303
72
+ else
73
+ response.status = 422
74
+ "Failed to update feature"
75
+ end
76
+ end
77
+
78
+ r.get "entities" do
79
+ view(:feature_entities, locals: { feature: FeaturePresenter.new(feature) })
80
+ end
81
+
82
+ r.post "add_entity" do
83
+ class_name, identifier = r.params.values_at("class_name", "identifier")
84
+ entity = Flipside.find_entity(class_name:, identifier:)
85
+ Flipside.add_entity(feature:, entity:)
86
+ r.redirect feature_path(feature, "entities"), 303
87
+ end
88
+
89
+ r.post "remove_entity" do
90
+ Flipside.remove_entity(feature:, entity_id: r.params["entity_id"])
91
+ r.redirect feature_path(feature, "entities"), 303
92
+ end
93
+
94
+ r.get "roles" do
95
+ view(:feature_roles, locals: { feature: FeaturePresenter.new(feature) })
96
+ end
97
+
98
+ r.post "add_role" do
99
+ class_name, method_name = r.params.values_at("class_name", "identifier")
100
+ Flipside.add_role(name:, class_name:, method_name:)
101
+ r.redirect feature_path(feature, "roles"), 303
102
+ end
103
+
104
+ r.post "remove_role" do
105
+ Flipside.remove_role(name:, role_id: r.params["role_id"])
106
+ r.redirect feature_path(feature, "roles"), 303
107
+ end
108
+ end
88
109
 
89
- def feature
90
- @feature ||= Flipside::Feature.find_by!(name: params["name"])
91
- rescue
92
- halt 404, "This feature does not exist"
93
- end
110
+ r.get "search_entity" do
111
+ class_name = r.params["class_name"]
112
+ query = URI.decode_www_form_component(r.params["q"])
113
+ result = Flipside.search_entity(class_name:, query:)
114
+ view(:_search_result, locals: { result:, class_name:, query: })
115
+ end
94
116
 
95
- def base_path
96
- @base_path ||= request.script_name
117
+ r.get "search_role" do
118
+ class_name = r.params["class_name"]
119
+ query = URI.decode_www_form_component(r.params["q"])
120
+ result = Flipside.search_role(class_name:, query:)
121
+ view(:_search_result, locals: { result:, class_name:, query: })
122
+ end
97
123
  end
98
124
  end
99
125
  end
data/lib/flipside.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_record"
3
4
  require "flipside/version"
4
5
  require "flipside/web"
5
6
  require "flipside/config/settings"
@@ -25,32 +26,36 @@ module Flipside
25
26
  feature = find_by(name:)
26
27
  return false unless feature
27
28
 
28
- objects << nil if objects.empty?
29
+ objects = [default_object] if objects.empty?
29
30
  objects.any? { |object| feature.enabled? object }
30
31
  end
31
32
 
33
+ def disabled?(...)
34
+ !enabled?(...)
35
+ end
36
+
32
37
  def enable!(name)
33
38
  feature = find_by!(name:)
34
39
  feature.update(enabled: true)
35
40
  end
36
41
 
37
- def add_entity(name:, entity:)
38
- feature = find_by!(name:)
42
+ def add_entity(entity:, feature: nil, name: nil)
43
+ feature ||= find_by!(name:)
39
44
  Entity.find_or_create_by(feature:, flippable: entity)
40
45
  end
41
46
 
42
- def remove_entity(name:, entity_id:)
43
- feature = find_by!(name:)
47
+ def remove_entity(entity_id:, feature: nil, name: nil)
48
+ feature ||= find_by!(name:)
44
49
  feature.entities.find_by(id: entity_id)&.destroy
45
50
  end
46
51
 
47
- def add_role(name:, class_name:, method_name:)
48
- feature = find_by!(name:)
52
+ def add_role(class_name:, method_name:, feature: nil, name: nil)
53
+ feature ||= find_by!(name:)
49
54
  Role.find_or_create_by(feature:, class_name:, method: method_name)
50
55
  end
51
56
 
52
- def remove_role(name:, role_id:)
53
- feature = find_by!(name:)
57
+ def remove_role(role_id:, feature: nil, name: nil)
58
+ feature ||= find_by!(name:)
54
59
  feature.roles.find_by(id: role_id)&.destroy
55
60
  end
56
61
 
@@ -64,11 +69,15 @@ module Flipside
64
69
  find_by(name:) || raise(NoSuchFeauture.new(name))
65
70
  end
66
71
 
72
+ def create(name:, description: nil)
73
+ Feature.create(name: name, description: description)
74
+ end
75
+
67
76
  def create_missing(name)
68
77
  trace = caller.find { |trace| !trace.start_with? __FILE__ }
69
78
  source, line, _ = trace.split(":")
70
79
  source = [source, line].join(":") if line.match?(/\d+/)
71
- Feature.create(name:, description: "Created from #{source}")
80
+ create(name:, description: "Created from #{source}")
72
81
  end
73
82
  end
74
83
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipside
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sammy Henningsson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-30 00:00:00.000000000 Z
11
+ date: 2025-05-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: roda
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: byebug
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +66,20 @@ dependencies:
52
66
  - - ">="
53
67
  - !ruby/object:Gem::Version
54
68
  version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rackup
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.2'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.2'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: rspec
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +108,20 @@ dependencies:
80
108
  - - ">="
81
109
  - !ruby/object:Gem::Version
82
110
  version: '2.1'
111
+ - !ruby/object:Gem::Dependency
112
+ name: puma
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '6.5'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '6.5'
83
125
  description: Create simple feature toggles.
84
126
  email:
85
127
  - sammy.henningsson@hey.com
@@ -105,10 +147,11 @@ files:
105
147
  - lib/flipside/config/roles.rb
106
148
  - lib/flipside/config/settings.rb
107
149
  - lib/flipside/feature_presenter.rb
150
+ - lib/flipside/importmap.rb
108
151
  - lib/flipside/public/index.js
152
+ - lib/flipside/public/inline_edit_controller.js
109
153
  - lib/flipside/public/modal_controller.js
110
154
  - lib/flipside/public/search_controller.js
111
- - lib/flipside/public/toggle_controller.js
112
155
  - lib/flipside/search_result.rb
113
156
  - lib/flipside/version.rb
114
157
  - lib/flipside/views/_datetime_modal.erb
@@ -1,29 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus";
2
-
3
- export default class ToggleController extends Controller {
4
- static targets = ["switch"]
5
- static values = {
6
- url: String,
7
- enabled: Boolean
8
- }
9
-
10
- async switch(event) {
11
- try {
12
- const data = {enable: !this.enabledValue}
13
- const response = await fetch(this.urlValue, {
14
- method: "PUT",
15
- headers: {"Content-Type": "application/json"},
16
- body: JSON.stringify(data)
17
- })
18
-
19
- if (response.ok) {
20
- location.reload()
21
- } else {
22
- const text = await response.text()
23
- console.error("Failed to update:", text)
24
- }
25
- } catch (error) {
26
- console.error("Error during the PUT request:", error)
27
- }
28
- }
29
- }