flipside 0.3.4 → 0.5.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: 658bbe055472e1a880352ecd5af0cbeacc9c0988274dc798610baadaeae7d33b
4
- data.tar.gz: 55dcb7671784fee2bdcef7fdd6f26390aa7cd1e7ebc9c443199eadd18bfaf614
3
+ metadata.gz: d4ecf5b08df6bcc2ac0bd7a9f2e4396d361fefb53b497323725ab543ed3d60a8
4
+ data.tar.gz: a64de96569181f04e4e7f50f9c7122299ee79581115c0abfbdf24aceef706f03
5
5
  SHA512:
6
- metadata.gz: 3170999fd42dcecf0232142f55430a9812e0926714f3ed543637ca5ce2b44dd932357e86b5c091b7c5e861de848ff4095eb9cc778e04311f84ecc9e4e8140796
7
- data.tar.gz: 57ee735a9edfed83fc66283d7747a5e75614a6dc2de56628b4671796ee7e9dad03c07c82cf844aff9d59cab6bb635cd3fdd694d44345b7e55f0594135b31b46a
6
+ metadata.gz: 6b8b659ad364668c26e19e2666f78e5c354034c61b68b1c3b54f63a07f310749cc6295fe3cd77b36c61bfefa41f3266cb9c047ce459d688fed5951f4ee00d8ef
7
+ data.tar.gz: d61860274364b069a96b7547bc4f0c66b0de08b8eae4205f520084afa5b9fd1bb2e8b2d1e0022b05c47a8a8b47ef0421dafef87cb126152b3d7d70c39bb03d80
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.5.0] - 2026-10-08
2
+ - Add `flipside_search_by`, `flipside_display_as` and `flipside_identified_by` to `Flipside::Flippable`. Each one registers the class as an entity, so `flipside_entity` is only needed for the defaults
3
+ - A block given to `flipside_search_by` runs in the context of the class, so it can call `where` directly
4
+ - An STI subclass inherits its parent's entity settings, and is registered as an entity of its own when it calls one of the macros itself
5
+ - Test against Ruby 3.4 and 4.0
6
+
7
+ ### Deprecated
8
+ - The `search_by`, `display_as` and `identified_by` options of `flipside_entity`, in favour of `flipside_search_by`, `flipside_display_as` and `flipside_identified_by`. They will be removed in 1.0.
9
+
10
+ ## [0.4.0] - 2026-09-25
11
+ - Show entities whose record has been deleted instead of raising in the UI
12
+ - Add `Flipside::Flippable` with `flipside_entity` and `flipside_role`, so models can register themselves (listed in `Flipside.flippables`); entities of a destroyed record are removed
13
+ - Add `Flipside.prune_orphaned_entities`
14
+ - Registering the same role twice no longer lists it twice
15
+ - Display STI subclass entities through their own or their base class's registration
16
+ - Verify `Flipside.flippables` at boot when a Rails app eager loads
17
+
18
+ ### Deprecated
19
+ - `Flipside.register_entity` and `Flipside.register_role`, in favour of `flipside_entity` and `flipside_role` from `Flipside::Flippable`. They will be removed in 1.0.
20
+
1
21
  ## [0.3.4] - 2026-03-04
2
22
  - Support an array of default objects
3
23
 
data/README.md CHANGED
@@ -87,7 +87,7 @@ We can also check multiple records. `.enabled?` will return `true` if any of the
87
87
  Flipside.enabled? "MyFeature", user, company, location
88
88
  ```
89
89
 
90
- ### Enabling features for specific records
90
+ #### Enabling features for specific records
91
91
 
92
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`:
93
93
  ```ruby
@@ -97,6 +97,13 @@ Flipside.add_entity(name: "MyFeature", entity: user)
97
97
  Flipside.enabled? "MyFeature", user # => true
98
98
  ```
99
99
 
100
+ Entities reference their record polymorphically, so there is no foreign key to clean them up when the record is deleted. A model registered as an entity (see [Registering from the model](#registering-from-the-model)) removes its entities when a record is destroyed.
101
+
102
+ Entities left behind by records deleted without callbacks (or before the concern was added) can be removed with:
103
+ ```ruby
104
+ Flipside.prune_orphaned_entities # => number of entities deleted
105
+ ```
106
+
100
107
  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
101
108
  for all users who are admins, using the `.add_role` method:
102
109
  ```ruby
@@ -123,7 +130,7 @@ Note: you probably want to wrap this inside a constraints block to provide some
123
130
  ![UI](/features.png)
124
131
 
125
132
 
126
- ### Configuration
133
+ ## Configuration
127
134
 
128
135
  Flipside can be configured by calling some class methods on `Flipside` (see below).
129
136
 
@@ -154,7 +161,7 @@ Flipside.enabled? :some_feature, Current.user
154
161
  Flipside.default_object = -> { [Current.user, Current.organization] }
155
162
  ```
156
163
 
157
- # Note: if we do pass in an argument, then `default_object ` will not be used:
164
+ #### Note: if we do pass in an argument, then `default_object ` will not be used:
158
165
  Flipside.enabled? :some_feature, Current.company # check current company instead of user.
159
166
  ```
160
167
 
@@ -170,75 +177,148 @@ Flipside.create_missing_features = true
170
177
  Flipside.default_object = -> { Current.user }
171
178
  ```
172
179
 
173
- #### Entities
180
+ ### Registering from the model
181
+
182
+ Entities and roles are searched for in the UI, so Flipside needs to know which classes can be used. A model registers itself by including `Flipside::Flippable` and calling the entity macros (`flipside_entity`, `flipside_search_by`, `flipside_display_as`, `flipside_identified_by`) and/or `flipside_role`:
183
+ ```ruby
184
+ class User < ApplicationRecord
185
+ include Flipside::Flippable
186
+
187
+ flipside_search_by :name
188
+ flipside_display_as :name
189
+ flipside_role :admin?
190
+ flipside_role :awesome?, display_as: "Awesome users"
191
+ end
192
+ ```
193
+
194
+ List the class names in an initializer, so that Flipside can load them when the UI needs them (also when the app does not eager load):
195
+ ```ruby
196
+ # config/initializers/flipside.rb
197
+ Flipside.flippables = %w[User]
198
+ ```
199
+
200
+ A class using these macros must be listed in `Flipside.flippables`, and a listed class must call at least one of them, otherwise Flipside raises.
201
+
202
+ ### Entities
174
203
 
175
204
  Entities can be added to a feature by searching for records.
176
205
 
177
206
  ![Add an entity](/add_entity.png)
178
207
 
179
- To make this work, some configuration is required. Use the class method `Flipside.register_entity` for this.
208
+ Each class that may be used as a feature enabler is registered as an entity by calling `flipside_entity`, `flipside_search_by`, `flipside_display_as` or `flipside_identified_by` (any combination of them, in any order). `flipside_entity` is only needed to register with the defaults. A registered entity class also removes the entities of a record when it is destroyed. Note that this includes soft deletion that runs destroy callbacks (e.g. paranoia), so a restored record comes back without its features. Soft deletion that skips them (e.g. discard) keeps the entities.
180
209
  ```ruby
181
- Flipside.register_entity(
182
- class_name: "User",
183
- search_by: :name,
184
- display_as: :name,
185
- identified_by: :id
186
- )
210
+ class User < ApplicationRecord
211
+ include Flipside::Flippable
212
+
213
+ flipside_entity
214
+ end
187
215
  ```
188
216
 
189
- The `.register_entity` method should be called once for each class that may be used as a feature enabler.
190
- The `search_by` keyword argument, which may be a `Symbol` or a `Proc`, dictates how records are found from searching in the ui.
191
- 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)`.
192
- 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).
217
+ `flipside_identified_by` sets the column Flipside uses to identify records. This defaults to `:id` and typically does not need to be changed.
218
+ Currently composite keys are not supported.
219
+ ```ruby
220
+ flipside_identified_by :uuid
221
+ ```
222
+
223
+ `flipside_search_by` dictates how records are found from searching in the ui. It takes a block, which is called with the search string in the context of the class and is expected to return an object responding to `to_a` (e.g. an AR collection).
193
224
  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
194
225
  starting with _query_, something like this could be used.
195
226
  ```ruby
196
- Flipside.register_entity(
197
- class_name: "User",
198
- search_by: ->(str) { User.where("lower(first_name) = :name OR lower(last_name) = :name or email LIKE :str", name: str.downcase, str: "#{str}%") },
199
- )
227
+ flipside_search_by do |str|
228
+ where(
229
+ "lower(first_name) = :name OR lower(last_name) = :name OR email LIKE :str",
230
+ name: str.downcase,
231
+ str: "#{str}%"
232
+ )
233
+ end
234
+ ```
200
235
 
236
+ Instead of a block, it may be given the name of a column, e.g. `:name`. Then records with an exact match on that column are returned, i.e. `User.where(name: query)`:
237
+ ```ruby
238
+ flipside_search_by :name
201
239
  ```
202
240
 
203
- 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.
204
- Currently composite keys are not supported.
241
+ Without `flipside_search_by`, records are found by an exact match on `identified_by`.
205
242
 
206
- 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.
243
+ `flipside_display_as` configures how these entities show up in the combobox. When given the name of an instance method, that method is called on the entity.
207
244
  For example, given the following setup. Users will be displayed with first name and last name:
208
245
  ```ruby
209
246
  class User < ApplicationRecord
247
+ include Flipside::Flippable
248
+
249
+ flipside_display_as :name
250
+
210
251
  def name
211
252
  [first_name, last_name].compact.map(&:capitalize).join(" ")
212
253
  end
213
254
  end
214
-
215
- Flipside.register_entity(
216
- class_name: "User",
217
- display_as: :name,
218
- )
219
255
  ```
220
256
 
221
- 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:
257
+ When given a block, it is called with the entity and is expected to return a string used for displaying the entity. The config above could then instead be done using:
222
258
  ```ruby
223
- Flipside.register_entity(
224
- class_name: "User",
225
- display_as: ->(user) { [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") }
226
- )
259
+ flipside_display_as do |user|
260
+ [user.first_name, user.last_name].compact.map(&:capitalize).join(" ")
261
+ end
227
262
  ```
228
263
 
229
- #### Roles
264
+ Without `flipside_display_as`, entities are displayed by their `identified_by` value.
265
+
266
+ An STI subclass inherits these settings. When a subclass calls one of the macros itself, it is registered as an entity of its own, so it must also be listed in `Flipside.flippables` and shows up separately in the UI.
267
+
268
+ The `search_by`, `display_as` and `identified_by` keyword arguments of `flipside_entity` from Flipside 0.4.0 are deprecated in favour of these macros, and will be removed in 1.0.
269
+
270
+ ### Roles
230
271
 
231
272
  Features can be enabled for certain roles, by searching for roles (by method name).
232
273
 
233
274
  ![Add a role](/add_role.png)
234
275
 
235
- This is configured by calling the class method `Flipside.register_role` for each role to be added.
236
- Note a role consists of a class and a corresponding instance method.
276
+ This is configured by calling `flipside_role` for each role to be added. A role consists of a class and a corresponding instance method, and `display_as` optionally sets how it shows up in the UI.
237
277
  ```ruby
278
+ class User < ApplicationRecord
279
+ include Flipside::Flippable
280
+
281
+ flipside_role :admin?
282
+ flipside_role :awesome?, display_as: "Awesome users"
283
+ end
284
+ ```
285
+
286
+ ### Classes you cannot edit
287
+
288
+ A class from another gem can be registered by including the concern from `to_prepare`, which runs at boot and again after every reload. It must still be listed in `Flipside.flippables`:
289
+ ```ruby
290
+ # config/initializers/flipside.rb
291
+ Flipside.flippables = %w[User OtherGem::Account]
292
+
293
+ Rails.application.config.to_prepare do
294
+ OtherGem::Account.include(Flipside::Flippable)
295
+ OtherGem::Account.flipside_display_as(:name)
296
+ end
297
+ ```
298
+
299
+ ### Upgrading from `register_entity` and `register_role`
300
+
301
+ `Flipside.register_entity` and `Flipside.register_role` are deprecated and will be removed in Flipside 1.0. Move each call into its model:
302
+ ```ruby
303
+ # Before, in config/initializers/flipside.rb
304
+ Flipside.register_entity(class_name: "User", search_by: :name, display_as: :name)
238
305
  Flipside.register_role(class_name: "User", method_name: :admin?)
239
- Flipside.register_role(class_name: "User", method_name: :awesome?)
306
+
307
+ # After, in config/initializers/flipside.rb
308
+ Flipside.flippables = %w[User]
309
+
310
+ # and in app/models/user.rb
311
+ class User < ApplicationRecord
312
+ include Flipside::Flippable
313
+
314
+ flipside_search_by :name
315
+ flipside_display_as :name
316
+ flipside_role :admin?
317
+ end
240
318
  ```
241
319
 
320
+ In a Rails 7.1+ app the deprecation warnings follow the app's own deprecation settings (`Rails.application.deprecators[:flipside]`).
321
+
242
322
  ## Development
243
323
 
244
324
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "models/flipside/entity"
6
+
7
+ module Flipside
8
+ # Lets a model register itself with Flipside. The class must also be listed in
9
+ # Flipside.flippables, so that Flipside can load it when the UI needs it. The
10
+ # listing is checked when Flipside loads the classes rather than here, since
11
+ # a model may be loaded before the initializer that sets Flipside.flippables.
12
+ module Flippable
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ # The class's Flipside::RegisteredEntity. Being a class attribute, it is
17
+ # inherited by STI subclasses and starts out empty in a reloaded class.
18
+ class_attribute :_flipside_registration, instance_accessor: false, instance_predicate: false
19
+ end
20
+
21
+ class_methods do
22
+ # Registers the class as an entity and removes its Flipside::Entity rows
23
+ # when a record is destroyed. Calling flipside_search_by,
24
+ # flipside_display_as or flipside_identified_by does the same, so this is
25
+ # only needed to register with the defaults.
26
+ def flipside_entity(search_by: nil, display_as: nil, identified_by: nil)
27
+ flipside_registration
28
+ deprecated_entity_options(search_by:, display_as:, identified_by:)
29
+ end
30
+
31
+ # Sets how entities are found from a search in the UI, either with a block
32
+ # or the name of a column. The block is run in the context of the class,
33
+ # given the search string, and returns the matching records. A column
34
+ # matches the search string exactly. Registers the class as an entity, as
35
+ # flipside_entity does.
36
+ def flipside_search_by(column = nil, &block)
37
+ flipside_registration.search_by = name_or_block(column, block)
38
+ end
39
+
40
+ # Sets how entities are displayed in the UI, either with a block given the
41
+ # record or the name of an instance method. Either returns a string.
42
+ # Registers the class as an entity, as flipside_entity does.
43
+ def flipside_display_as(method_name = nil, &block)
44
+ flipside_registration.display_as = name_or_block(method_name, block)
45
+ end
46
+
47
+ # Sets the column that identifies an entity, :id by default. Registers the
48
+ # class as an entity, as flipside_entity does.
49
+ def flipside_identified_by(column)
50
+ flipside_registration.identified_by = column.to_sym
51
+ end
52
+
53
+ def flipside_role(method_name, display_as: nil)
54
+ register_flippable!
55
+
56
+ Flipside.add_registered_role(class_name: name, method_name:, display_as:)
57
+ end
58
+
59
+ private
60
+
61
+ def register_flippable!
62
+ raise Flipside::Error, "Flipside macros need a named class" if name.nil?
63
+
64
+ Flipside.register_flippable(name)
65
+ end
66
+
67
+ # The registration this class owns, created on first use. An STI subclass
68
+ # starts from a copy of the settings it inherits.
69
+ def flipside_registration
70
+ return _flipside_registration if _flipside_registration&.class_name == name
71
+
72
+ register_flippable!
73
+
74
+ has_many :flipside_entities,
75
+ class_name: "Flipside::Entity",
76
+ as: :flippable,
77
+ dependent: :delete_all
78
+
79
+ self._flipside_registration = Flipside.add_registered_entity(
80
+ class_name: name,
81
+ **(_flipside_registration&.options || {})
82
+ )
83
+ end
84
+
85
+ def name_or_block(name, block)
86
+ return name.to_sym if name && !block
87
+ return block if block && !name
88
+
89
+ raise ArgumentError, "pass either a name or a block"
90
+ end
91
+
92
+ def deprecated_entity_options(search_by:, display_as:, identified_by:)
93
+ return if search_by.nil? && display_as.nil? && identified_by.nil?
94
+
95
+ Flipside.deprecator.warn(
96
+ "The search_by, display_as and identified_by options of flipside_entity are " \
97
+ "deprecated and will be removed in Flipside #{Flipside.deprecator.deprecation_horizon}. " \
98
+ "Use flipside_search_by, flipside_display_as and flipside_identified_by in " \
99
+ "#{name} instead."
100
+ )
101
+
102
+ registration = flipside_registration
103
+ registration.search_by = search_by if search_by
104
+ registration.display_as = display_as if display_as
105
+ registration.identified_by = identified_by if identified_by
106
+ end
107
+ end
108
+ end
109
+ end
@@ -4,6 +4,15 @@ module Flipside
4
4
  module Config
5
5
  module Entities
6
6
  def register_entity(class_name:, search_by:, display_as:, identified_by: :id)
7
+ deprecator.warn(
8
+ "Flipside.register_entity is deprecated and will be removed in Flipside " \
9
+ "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \
10
+ "#{class_name}, call flipside_entity there and list it in Flipside.flippables."
11
+ )
12
+ add_registered_entity(class_name:, search_by:, display_as:, identified_by:)
13
+ end
14
+
15
+ def add_registered_entity(class_name:, search_by: nil, display_as: nil, identified_by: :id) # :nodoc:
7
16
  registered_entities[class_name.to_s] = RegisteredEntity.new(
8
17
  class_name:,
9
18
  search_by:,
@@ -13,25 +22,61 @@ module Flipside
13
22
  end
14
23
 
15
24
  def entity_classes
25
+ load_flippables
16
26
  registered_entities.keys
17
27
  end
18
28
 
19
29
  def search_entity(class_name:, query:)
30
+ load_flippables
20
31
  registered_entities.fetch(class_name.to_s).search(query)
21
32
  end
22
33
 
23
34
  def find_entity(class_name:, identifier:)
35
+ load_flippables
24
36
  registered_entities.fetch(class_name.to_s).find(identifier)
25
37
  end
26
38
 
39
+ # Accepts either a flippable record or a Flipside::Entity join row. Given
40
+ # a join row, it still renders when the record or its class has been
41
+ # deleted, or the class is no longer registered, so the UI can list it
42
+ # for removal.
27
43
  def display_entity(entity)
28
- registered_entities
29
- .fetch(entity.class.to_s)
30
- .display(entity)
44
+ return display_flippable(entity) unless entity.is_a?(Flipside::Entity)
45
+
46
+ record = entity.flippable if entity.flippable_type.safe_constantize
47
+ label = "#{entity.flippable_type} ##{entity.flippable_id}"
48
+
49
+ if record.nil?
50
+ "#{label} (deleted)"
51
+ elsif (registration = registration_for(record.class))
52
+ registration.display(record)
53
+ else
54
+ label
55
+ end
31
56
  end
32
57
 
33
58
  private
34
59
 
60
+ def display_flippable(record)
61
+ registration = registration_for(record.class)
62
+ raise KeyError, "#{record.class} is not registered with Flipside" unless registration
63
+
64
+ registration.display(record)
65
+ end
66
+
67
+ # The registration of the class or its closest registered superclass, so
68
+ # an STI subclass is displayed through the base class's registration
69
+ # unless it has one of its own.
70
+ def registration_for(klass)
71
+ klass.ancestors.each do |ancestor|
72
+ next unless ancestor.is_a?(Class)
73
+
74
+ registration = registered_entities[ancestor.name]
75
+ return registration if registration
76
+ end
77
+ nil
78
+ end
79
+
35
80
  def registered_entities
36
81
  @registered_entities ||= {}
37
82
  end
@@ -0,0 +1,46 @@
1
+ module Flipside
2
+ module Config
3
+ # Names of the classes that register themselves through the
4
+ # Flipside::Flippable macros. Listing them lets Flipside load them on demand,
5
+ # so the UI finds them whether or not the app eager loads.
6
+ module Flippables
7
+ attr_writer :flippables
8
+
9
+ def flippables
10
+ @flippables ||= []
11
+ end
12
+
13
+ def register_flippable(class_name) # :nodoc:
14
+ registered_flippables << class_name.to_s
15
+ end
16
+
17
+ # Raises unless Flipside.flippables and the classes calling the
18
+ # Flipside::Flippable macros agree. Runs whenever the UI reads the
19
+ # registry, and at boot in an app that eager loads (see Railtie).
20
+ def verify_flippables! # :nodoc:
21
+ load_flippables
22
+ end
23
+
24
+ private
25
+
26
+ def load_flippables
27
+ flippables.each do |name|
28
+ name.constantize
29
+ next if registered_entities.key?(name) || registered_roles.key?(name)
30
+
31
+ raise Error, "#{name} is listed in Flipside.flippables but calls neither " \
32
+ "flipside_entity nor flipside_role"
33
+ end
34
+
35
+ unlisted = registered_flippables.to_a - flippables
36
+ return if unlisted.empty?
37
+
38
+ raise Error, "#{unlisted.join(", ")} must be listed in Flipside.flippables"
39
+ end
40
+
41
+ def registered_flippables
42
+ @registered_flippables ||= Set.new
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,7 +2,8 @@ require "flipside/search_result"
2
2
 
3
3
  module Flipside
4
4
  class RegisteredEntity
5
- attr_reader :class_name, :search_by, :display_as, :identified_by
5
+ attr_reader :class_name
6
+ attr_accessor :search_by, :display_as, :identified_by
6
7
 
7
8
  def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil)
8
9
  @class_name = class_name
@@ -11,8 +12,12 @@ module Flipside
11
12
  @identified_by = identified_by
12
13
  end
13
14
 
15
+ def options
16
+ {search_by:, display_as:, identified_by:}
17
+ end
18
+
14
19
  def search(query)
15
- Array(lookup_proc.call(query)).map do |entity|
20
+ Array(lookup(query)).map do |entity|
16
21
  SearchResult.new(
17
22
  entity,
18
23
  display(entity),
@@ -22,39 +27,32 @@ module Flipside
22
27
  end
23
28
 
24
29
  def find(identifier)
25
- class_name.constantize.find_by!("#{identified_by}": identifier)
30
+ klass.find_by!("#{identified_by}": identifier)
26
31
  end
27
32
 
28
33
  def display(entity)
29
- display_proc.call(entity)
34
+ case display_as
35
+ when Proc then display_as.call(entity)
36
+ when Symbol then entity.public_send(display_as)
37
+ when String then display_as
38
+ else entity.public_send(identified_by)
39
+ end
30
40
  end
31
41
 
32
42
  private
33
43
 
34
- def lookup_proc
35
- @lookup_proc ||=
36
- case search_by
37
- when Proc
38
- search_by
39
- when Symbol
40
- ->(query) { class_name.constantize.where("#{search_by}": query) }
41
- else
42
- ->(query) { class_name.constantize.where("#{identified_by}": query) }
43
- end
44
+ # A block runs in the context of the class, so it can call where directly.
45
+ # A Symbol names a column to match exactly.
46
+ def lookup(query)
47
+ case search_by
48
+ when Proc then klass.instance_exec(query, &search_by)
49
+ when Symbol then klass.where("#{search_by}": query)
50
+ else klass.where("#{identified_by}": query)
51
+ end
44
52
  end
45
53
 
46
- def display_proc
47
- @display_proc ||=
48
- case display_as
49
- when Proc
50
- display_as
51
- when Symbol
52
- ->(entity) { entity.public_send(display_as) }
53
- when String
54
- ->(entity) { display_as }
55
- else
56
- ->(entity) { entity.public_send(identified_by) }
57
- end
54
+ def klass
55
+ class_name.constantize
58
56
  end
59
57
  end
60
58
  end
@@ -4,8 +4,17 @@ module Flipside
4
4
  module Config
5
5
  module Roles
6
6
  def register_role(class_name:, method_name:, display_as: nil)
7
- registered_roles[class_name.to_s] ||= []
8
- registered_roles[class_name.to_s] << RegisteredRole.new(
7
+ deprecator.warn(
8
+ "Flipside.register_role is deprecated and will be removed in Flipside " \
9
+ "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \
10
+ "#{class_name}, call flipside_role there and list it in Flipside.flippables."
11
+ )
12
+ add_registered_role(class_name:, method_name:, display_as:)
13
+ end
14
+
15
+ def add_registered_role(class_name:, method_name:, display_as: nil) # :nodoc:
16
+ registered_roles[class_name.to_s] ||= {}
17
+ registered_roles[class_name.to_s][method_name.to_s] = RegisteredRole.new(
9
18
  class_name:,
10
19
  method_name:,
11
20
  display_as:
@@ -13,11 +22,13 @@ module Flipside
13
22
  end
14
23
 
15
24
  def role_classes
25
+ load_flippables
16
26
  registered_roles.keys
17
27
  end
18
28
 
19
29
  def search_role(class_name:, query:)
20
- registered_roles.fetch(class_name.to_s).filter_map do |registered_role|
30
+ load_flippables
31
+ registered_roles.fetch(class_name.to_s).values.filter_map do |registered_role|
21
32
  next unless registered_role.match? query
22
33
  registered_role.to_result
23
34
  end
@@ -0,0 +1,18 @@
1
+ module Flipside
2
+ # Wires Flipside into a Rails app. Only loaded when Rails is present.
3
+ class Railtie < ::Rails::Railtie
4
+ # Hands Flipside's deprecator to the app, so its deprecation settings (log,
5
+ # raise, silence) apply to Flipside's warnings too.
6
+ initializer "flipside.deprecator" do |app|
7
+ app.deprecators[:flipside] = Flipside.deprecator if app.respond_to?(:deprecators)
8
+ end
9
+
10
+ # Eager loading runs before after_initialize, so every class calling the
11
+ # macros has registered by now. Checking here fails boot, and so CI, for a
12
+ # misconfigured list instead of the first visit to the UI. Without eager
13
+ # loading the check would only see whatever happens to be loaded.
14
+ config.after_initialize do |app|
15
+ Flipside.verify_flippables! if app.config.eager_load
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Flipside
4
- VERSION = "0.3.4"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -51,7 +51,7 @@
51
51
  <li class="mt-2 p-2 bg-slate-700 text-slate-300 border border-slate-600 rounded-lg">
52
52
  <div class="flex justify-between items-center">
53
53
  <div>
54
- <%= Flipside.display_entity(entity.flippable) %> (<%= entity.flippable_type %>)
54
+ <%= Flipside.display_entity(entity) %> (<%= entity.flippable_type %>)
55
55
  </div>
56
56
  <div>
57
57
  <form action="<%= feature_path(feature, "remove_entity") %>" method="post">
data/lib/flipside.rb CHANGED
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record"
4
+ require "active_support/deprecation"
4
5
  require "flipside/version"
5
6
  require "flipside/web"
6
7
  require "flipside/config/settings"
7
8
  require "flipside/config/entities"
8
9
  require "flipside/config/roles"
10
+ require "flipside/config/flippables"
9
11
  require "models/flipside/feature"
12
+ require "models/flipside/flippable"
13
+ require "flipside/railtie" if defined?(Rails::Railtie)
10
14
 
11
15
  module Flipside
12
16
  extend Config::Settings
13
17
  extend Config::Entities
14
18
  extend Config::Roles
19
+ extend Config::Flippables
15
20
 
16
21
  class Error < StandardError; end
17
22
 
@@ -22,6 +27,10 @@ module Flipside
22
27
  end
23
28
 
24
29
  class << self
30
+ def deprecator
31
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "Flipside")
32
+ end
33
+
25
34
  def enabled?(name, *objects)
26
35
  feature = find_by(name:)
27
36
  return false unless feature
@@ -40,6 +49,11 @@ module Flipside
40
49
  feature.update(enabled: true)
41
50
  end
42
51
 
52
+ def disable!(name)
53
+ feature = find_by!(name:)
54
+ feature.update(enabled: false)
55
+ end
56
+
43
57
  def add_entity(entity:, feature: nil, name: nil)
44
58
  feature ||= find_by!(name:)
45
59
  Entity.find_or_create_by(feature:, flippable: entity)
@@ -50,6 +64,22 @@ module Flipside
50
64
  feature.entities.find_by(id: entity_id)&.destroy
51
65
  end
52
66
 
67
+ # Deletes entities whose record no longer exists, or whose class does not.
68
+ # Returns the number of entities deleted.
69
+ def prune_orphaned_entities
70
+ Entity.distinct.pluck(:flippable_type).sum do |type|
71
+ entities = Entity.where(flippable_type: type)
72
+ klass = type.safe_constantize
73
+ next entities.delete_all unless klass
74
+
75
+ existing = klass
76
+ .unscoped
77
+ .where(klass.primary_key => entities.pluck(:flippable_id))
78
+ .pluck(klass.primary_key)
79
+ entities.where.not(flippable_id: existing).delete_all
80
+ end
81
+ end
82
+
53
83
  def add_role(class_name:, method_name:, feature: nil, name: nil)
54
84
  feature ||= find_by!(name:)
55
85
  Role.find_or_create_by(feature:, class_name:, method: method_name)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipside
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sammy Henningsson
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-03-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -136,12 +135,14 @@ files:
136
135
  - app/models/flipside/entities.rb
137
136
  - app/models/flipside/entity.rb
138
137
  - app/models/flipside/feature.rb
138
+ - app/models/flipside/flippable.rb
139
139
  - app/models/flipside/key.rb
140
140
  - app/models/flipside/keys.rb
141
141
  - app/models/flipside/role.rb
142
142
  - app/models/flipside/roles.rb
143
143
  - lib/flipside.rb
144
144
  - lib/flipside/config/entities.rb
145
+ - lib/flipside/config/flippables.rb
145
146
  - lib/flipside/config/registered_entity.rb
146
147
  - lib/flipside/config/registered_role.rb
147
148
  - lib/flipside/config/roles.rb
@@ -152,6 +153,7 @@ files:
152
153
  - lib/flipside/public/inline_edit_controller.js
153
154
  - lib/flipside/public/modal_controller.js
154
155
  - lib/flipside/public/search_controller.js
156
+ - lib/flipside/railtie.rb
155
157
  - lib/flipside/search_result.rb
156
158
  - lib/flipside/version.rb
157
159
  - lib/flipside/views/_datetime_modal.erb
@@ -174,7 +176,6 @@ metadata:
174
176
  homepage_uri: https://github.com/apoex/flipside
175
177
  source_code_uri: https://github.com/apoex/flipside
176
178
  changelog_uri: https://github.com/apoex/flipside/blob/main/CHANGELOG.md
177
- post_install_message:
178
179
  rdoc_options: []
179
180
  require_paths:
180
181
  - app
@@ -190,8 +191,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
191
  - !ruby/object:Gem::Version
191
192
  version: '0'
192
193
  requirements: []
193
- rubygems_version: 3.5.11
194
- signing_key:
194
+ rubygems_version: 4.0.20
195
195
  specification_version: 4
196
196
  summary: Feature flags.
197
197
  test_files: []