From 6e0186457a3f0260aa170f054f8646d1d0d2da6c Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 10:44:10 +0200 Subject: [PATCH 01/12] Render entities whose record has been deleted A polymorphic flippable cannot carry a foreign key, so the host app can delete the record behind an entity without Flipside noticing. The entities page then passed nil to display_entity, which looked up the registration by nil.class and raised KeyError (key not found: "NilClass"), taking down the page that holds the Remove button. display_entity now also accepts the Flipside::Entity join row, which always knows the flippable type and id, and labels a missing record (or a class that is no longer registered) instead of raising. Passing a record directly still works. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ lib/flipside/config/entities.rb | 24 +++++++++++++++++++--- lib/flipside/views/feature_entities.erb | 2 +- spec/flipside_spec.rb | 27 +++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2563e88..0769efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [Unreleased] +- Show entities whose record has been deleted instead of raising in the UI + ## [0.3.4] - 2026-03-04 - Support an array of default objects diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index 194f8de..3a435ff 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -24,14 +24,32 @@ def find_entity(class_name:, identifier:) registered_entities.fetch(class_name.to_s).find(identifier) end + # Accepts either a flippable record or a Flipside::Entity join row. Given + # a join row, it still renders when the record has been deleted or its + # class is no longer registered, so the UI can list it for removal. def display_entity(entity) - registered_entities - .fetch(entity.class.to_s) - .display(entity) + return display_flippable(entity) unless entity.is_a?(Flipside::Entity) + + record = entity.flippable + label = "#{entity.flippable_type} ##{entity.flippable_id}" + + if record.nil? + "#{label} (deleted)" + elsif registered_entities.key?(entity.flippable_type) + display_flippable(record) + else + label + end end private + def display_flippable(record) + registered_entities + .fetch(record.class.to_s) + .display(record) + end + def registered_entities @registered_entities ||= {} end diff --git a/lib/flipside/views/feature_entities.erb b/lib/flipside/views/feature_entities.erb index 1dc764f..9b6893d 100644 --- a/lib/flipside/views/feature_entities.erb +++ b/lib/flipside/views/feature_entities.erb @@ -51,7 +51,7 @@
  • - <%= Flipside.display_entity(entity.flippable) %> (<%= entity.flippable_type %>) + <%= Flipside.display_entity(entity) %> (<%= entity.flippable_type %>)
    " method="post"> diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index 174f1d5..68ef55b 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -281,6 +281,33 @@ def admin? expect(Flipside.display_entity(user)).to eq("John Doe") end + + it "displays the flippable of an entity" do + Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + feature = Feature.create!(name: "some_feature") + user = User.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: user) + + expect(Flipside.display_entity(entity)).to eq("John Doe") + end + + it "displays an entity whose flippable has been deleted" do + Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + feature = Feature.create!(name: "some_feature") + user = User.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: user) + user.delete + + expect(Flipside.display_entity(entity.reload)).to eq("User ##{user.id} (deleted)") + end + + it "displays an entity whose class is no longer registered" do + feature = Feature.create!(name: "some_feature") + user = User.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: user) + + expect(Flipside.display_entity(entity)).to eq("User ##{user.id}") + end end end end From 1030734ecdb8479ad304c5d62341eb0fed407b6d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 10:44:34 +0200 Subject: [PATCH 02/12] Add Flippable concern to clean up entities on destroy Entities point at their record polymorphically, so the database cannot remove them when the record goes away. Host apps can now include Flipside::Flippable in an entity model to delete its entity rows when a record is destroyed. It is an explicit include rather than something register_entity wires up: registration runs in an initializer before models load, and patching a class from outside would be lost on the next Zeitwerk reload. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 7 +++++++ app/models/flipside/flippable.rb | 19 +++++++++++++++++ lib/flipside.rb | 1 + spec/flipside/flippable_spec.rb | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+) create mode 100644 app/models/flipside/flippable.rb create mode 100644 spec/flipside/flippable_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 0769efb..7c65b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] - Show entities whose record has been deleted instead of raising in the UI +- Add `Flipside::Flippable` to remove a record's entities when it is destroyed ## [0.3.4] - 2026-03-04 - Support an array of default objects diff --git a/README.md b/README.md index cc19c18..d698844 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ Flipside.add_entity(name: "MyFeature", entity: user) Flipside.enabled? "MyFeature", user # => true ``` +Entities reference their record polymorphically, so there is no foreign key to clean them up when the record is deleted. Include `Flipside::Flippable` in the model to remove its entities when a record is destroyed: +```ruby +class User < ApplicationRecord + include Flipside::Flippable +end +``` + 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 for all users who are admins, using the `.add_role` method: ```ruby diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb new file mode 100644 index 0000000..bbbc58f --- /dev/null +++ b/app/models/flipside/flippable.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "active_support/concern" +require "models/flipside/entity" + +module Flipside + # Include in models used as entities, to remove their Flipside::Entity rows + # when a record is destroyed. + module Flippable + extend ActiveSupport::Concern + + included do + has_many :flipside_entities, + class_name: "Flipside::Entity", + as: :flippable, + dependent: :delete_all + end + end +end diff --git a/lib/flipside.rb b/lib/flipside.rb index 1334f6d..8d918f4 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -7,6 +7,7 @@ require "flipside/config/entities" require "flipside/config/roles" require "models/flipside/feature" +require "models/flipside/flippable" module Flipside extend Config::Settings diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb new file mode 100644 index 0000000..fe50ee6 --- /dev/null +++ b/spec/flipside/flippable_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Flipside + RSpec.describe Flippable, type: :model do + let(:user_class) do + Class.new(ActiveRecord::Base) do + include Flipside::Flippable + end + end + + let(:feature) { Feature.create!(name: "some_feature") } + + before do + stub_const("User", user_class) + ActiveRecord::Base.connection.create_table :users, force: true do |t| + t.string(:name) + end + end + + after do + ActiveRecord::Base.connection.drop_table(:users, if_exists: true) + end + + it "removes the entities of a destroyed record" do + user = User.create!(name: "John Doe") + other_user = User.create!(name: "Jane Doe") + Entity.create!(feature:, flippable: user) + other_entity = Entity.create!(feature:, flippable: other_user) + + user.destroy + + expect(Entity.all).to eq([other_entity]) + end + end +end From 209b1c2423c16c69fae4548717586bf164e79fc1 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 10:44:56 +0200 Subject: [PATCH 03/12] Add Flipside.prune_orphaned_entities Removes entities left behind by records deleted without callbacks, or before Flipside::Flippable was included, and by entity classes that no longer exist. Ids are plucked rather than joined so it also works when a flippable model lives in another database. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 5 +++++ lib/flipside.rb | 15 +++++++++++++++ spec/flipside_spec.rb | 22 ++++++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c65b6d..fae24a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] - Show entities whose record has been deleted instead of raising in the UI - Add `Flipside::Flippable` to remove a record's entities when it is destroyed +- Add `Flipside.prune_orphaned_entities` ## [0.3.4] - 2026-03-04 - Support an array of default objects diff --git a/README.md b/README.md index d698844..ccc2f08 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,11 @@ class User < ApplicationRecord end ``` +Entities left behind by records deleted without callbacks (or before the concern was added) can be removed with: +```ruby +Flipside.prune_orphaned_entities # => number of entities deleted +``` + 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 for all users who are admins, using the `.add_role` method: ```ruby diff --git a/lib/flipside.rb b/lib/flipside.rb index 8d918f4..1bff7de 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -56,6 +56,21 @@ def remove_entity(entity_id:, feature: nil, name: nil) feature.entities.find_by(id: entity_id)&.destroy end + # Deletes entities whose record no longer exists, or whose class does not. + # Returns the number of entities deleted. + def prune_orphaned_entities + Entity.distinct.pluck(:flippable_type).sum do |type| + entities = Entity.where(flippable_type: type) + klass = type.safe_constantize + next entities.delete_all unless klass + + existing = klass + .where(klass.primary_key => entities.pluck(:flippable_id)) + .pluck(klass.primary_key) + entities.where.not(flippable_id: existing).delete_all + end + end + def add_role(class_name:, method_name:, feature: nil, name: nil) feature ||= find_by!(name:) Role.find_or_create_by(feature:, class_name:, method: method_name) diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index 68ef55b..2cd417d 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -257,6 +257,28 @@ def admin? end end + describe ".prune_orphaned_entities" do + it "deletes entities whose record has been deleted" do + feature = Feature.create!(name: "some_feature") + user = User.create!(name: "John Doe") + other_user = User.create!(name: "Jane Doe") + Entity.create!(feature:, flippable: user) + other_entity = Entity.create!(feature:, flippable: other_user) + user.delete + + expect(Flipside.prune_orphaned_entities).to eq(1) + expect(Entity.all).to eq([other_entity]) + end + + it "deletes entities whose class no longer exists" do + feature = Feature.create!(name: "some_feature") + Entity.create!(feature:, flippable_type: "Removed", flippable_id: 1) + + expect(Flipside.prune_orphaned_entities).to eq(1) + expect(Entity.count).to eq(0) + end + end + describe ".register_entity" do after do Flipside.send(:registered_entities).clear From 154fec4c21a2dbdab16950940c5572632efdb70a Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 12:02:45 +0200 Subject: [PATCH 04/12] Register each role only once register_role appended to a list, so registering the same class and method again listed the role twice in the UI. Key registered roles by method name so a repeated registration replaces the previous one, which lets registration run again whenever a class is reloaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + lib/flipside/config/roles.rb | 6 +++--- spec/flipside_spec.rb | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fae24a4..31a8514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Show entities whose record has been deleted instead of raising in the UI - Add `Flipside::Flippable` to remove a record's entities when it is destroyed - Add `Flipside.prune_orphaned_entities` +- Registering the same role twice no longer lists it twice ## [0.3.4] - 2026-03-04 - Support an array of default objects diff --git a/lib/flipside/config/roles.rb b/lib/flipside/config/roles.rb index 765e9b2..166702e 100644 --- a/lib/flipside/config/roles.rb +++ b/lib/flipside/config/roles.rb @@ -4,8 +4,8 @@ module Flipside module Config module Roles def register_role(class_name:, method_name:, display_as: nil) - registered_roles[class_name.to_s] ||= [] - registered_roles[class_name.to_s] << RegisteredRole.new( + registered_roles[class_name.to_s] ||= {} + registered_roles[class_name.to_s][method_name.to_s] = RegisteredRole.new( class_name:, method_name:, display_as: @@ -17,7 +17,7 @@ def role_classes end def search_role(class_name:, query:) - registered_roles.fetch(class_name.to_s).filter_map do |registered_role| + registered_roles.fetch(class_name.to_s).values.filter_map do |registered_role| next unless registered_role.match? query registered_role.to_result end diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index 2cd417d..7135031 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -292,6 +292,21 @@ def admin? end end + describe ".register_role" do + after do + Flipside.send(:registered_roles).clear + end + + it "registers the same role only once" do + Flipside.register_role(class_name: "User", method_name: :admin?) + Flipside.register_role(class_name: "User", method_name: :admin?, display_as: "Admin") + + results = Flipside.search_role(class_name: "User", query: "admin") + + expect(results.map(&:display_as)).to eq(["Admin"]) + end + end + describe ".display_entity" do after do Flipside.send(:registered_entities).clear From 590a54a86bfe0d439423d3913bedb535ecb987c2 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 12:04:02 +0200 Subject: [PATCH 05/12] Let models register themselves through Flipside::Flippable Models can now declare how they are used as entities and roles next to the code they describe: class User < ApplicationRecord include Flipside::Flippable flipside_entity search_by: :name, display_as: :name flipside_role :admin? end The macros register when the class loads, which in an app that does not eager load is only once something references it. The initializer therefore lists the class names in Flipside.flippables, and Flipside constantizes them before the UI reads the registry. That loads them the same way whether or not the app eager loads, keeps the gem free of Rails hooks, and picks up a reloaded class. A macro in an unlisted class, or a listed class that registers nothing, raises instead of appearing in one environment only. flipside_entity now also declares the association that removes a record's entities on destroy, so including the concern no longer does it on its own. register_entity and register_role remain for classes the app cannot edit. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 +- README.md | 27 +++++++-- app/models/flipside/flippable.rb | 37 +++++++++--- lib/flipside.rb | 2 + lib/flipside/config/entities.rb | 3 + lib/flipside/config/flippables.rb | 26 +++++++++ lib/flipside/config/roles.rb | 2 + spec/flipside/flippable_spec.rb | 94 ++++++++++++++++++++++++++----- 8 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 lib/flipside/config/flippables.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a8514..059da80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [Unreleased] - Show entities whose record has been deleted instead of raising in the UI -- Add `Flipside::Flippable` to remove a record's entities when it is destroyed +- 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 - Add `Flipside.prune_orphaned_entities` - Registering the same role twice no longer lists it twice diff --git a/README.md b/README.md index ccc2f08..a0995c9 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,7 @@ Flipside.add_entity(name: "MyFeature", entity: user) Flipside.enabled? "MyFeature", user # => true ``` -Entities reference their record polymorphically, so there is no foreign key to clean them up when the record is deleted. Include `Flipside::Flippable` in the model to remove its entities when a record is destroyed: -```ruby -class User < ApplicationRecord - include Flipside::Flippable -end -``` +Entities reference their record polymorphically, so there is no foreign key to clean them up when the record is deleted. A model registered with `flipside_entity` (see [Registering from the model](#registering-from-the-model)) removes its entities when a record is destroyed. Entities left behind by records deleted without callbacks (or before the concern was added) can be removed with: ```ruby @@ -251,6 +246,26 @@ Flipside.register_role(class_name: "User", method_name: :admin?) Flipside.register_role(class_name: "User", method_name: :awesome?) ``` +### Registering from the model + +Instead of registering entities and roles in an initializer, a model can register itself by including `Flipside::Flippable`. List the class names in the initializer, so that Flipside can load them when the UI needs them (also when the app does not eager load): +```ruby +# config/initializers/flipside.rb +Flipside.flippables = %w[User] +``` + +```ruby +class User < ApplicationRecord + include Flipside::Flippable + + flipside_entity search_by: :name, display_as: :name + flipside_role :admin? + flipside_role :awesome?, display_as: "Awesome users" +end +``` + +`flipside_entity` takes the same options as `Flipside.register_entity`, and `flipside_role` those of `Flipside.register_role`. `flipside_entity` also removes the entities of a record when it is destroyed. 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. + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests. diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index bbbc58f..72ac648 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -4,16 +4,39 @@ require "models/flipside/entity" module Flipside - # Include in models used as entities, to remove their Flipside::Entity rows - # when a record is destroyed. + # Lets a model register itself with Flipside. The class must also be listed in + # Flipside.flippables, so that Flipside can load it when the UI needs it. module Flippable extend ActiveSupport::Concern - included do - has_many :flipside_entities, - class_name: "Flipside::Entity", - as: :flippable, - dependent: :delete_all + class_methods do + # Registers the class as an entity and removes its Flipside::Entity rows + # when a record is destroyed. + def flipside_entity(search_by: nil, display_as: nil, identified_by: :id) + ensure_listed_in_flippables! + + has_many :flipside_entities, + class_name: "Flipside::Entity", + as: :flippable, + dependent: :delete_all + + Flipside.register_entity(class_name: name, search_by:, display_as:, identified_by:) + end + + def flipside_role(method_name, display_as: nil) + ensure_listed_in_flippables! + + Flipside.register_role(class_name: name, method_name:, display_as:) + end + + private + + def ensure_listed_in_flippables! + raise Flipside::Error, "Flipside macros need a named class" if name.nil? + return if Flipside.flippables.include?(name) + + raise Flipside::Error, "#{name} must be listed in Flipside.flippables" + end end end end diff --git a/lib/flipside.rb b/lib/flipside.rb index 1bff7de..ddd7657 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -6,6 +6,7 @@ require "flipside/config/settings" require "flipside/config/entities" require "flipside/config/roles" +require "flipside/config/flippables" require "models/flipside/feature" require "models/flipside/flippable" @@ -13,6 +14,7 @@ module Flipside extend Config::Settings extend Config::Entities extend Config::Roles + extend Config::Flippables class Error < StandardError; end diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index 3a435ff..2d6a6d3 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -13,14 +13,17 @@ def register_entity(class_name:, search_by:, display_as:, identified_by: :id) end def entity_classes + load_flippables registered_entities.keys end def search_entity(class_name:, query:) + load_flippables registered_entities.fetch(class_name.to_s).search(query) end def find_entity(class_name:, identifier:) + load_flippables registered_entities.fetch(class_name.to_s).find(identifier) end diff --git a/lib/flipside/config/flippables.rb b/lib/flipside/config/flippables.rb new file mode 100644 index 0000000..ed5c27a --- /dev/null +++ b/lib/flipside/config/flippables.rb @@ -0,0 +1,26 @@ +module Flipside + module Config + # Names of the classes that register themselves through the + # Flipside::Flippable macros. Listing them lets Flipside load them on demand, + # so the UI finds them whether or not the app eager loads. + module Flippables + attr_writer :flippables + + def flippables + @flippables ||= [] + end + + private + + def load_flippables + flippables.each do |name| + name.constantize + next if registered_entities.key?(name) || registered_roles.key?(name) + + raise Error, "#{name} is listed in Flipside.flippables but calls neither " \ + "flipside_entity nor flipside_role" + end + end + end + end +end diff --git a/lib/flipside/config/roles.rb b/lib/flipside/config/roles.rb index 166702e..2744ad3 100644 --- a/lib/flipside/config/roles.rb +++ b/lib/flipside/config/roles.rb @@ -13,10 +13,12 @@ def register_role(class_name:, method_name:, display_as: nil) end def role_classes + load_flippables registered_roles.keys end def search_role(class_name:, query:) + load_flippables registered_roles.fetch(class_name.to_s).values.filter_map do |registered_role| next unless registered_role.match? query registered_role.to_result diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index fe50ee6..3d3b10e 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -1,17 +1,12 @@ # frozen_string_literal: true +require "tmpdir" + module Flipside RSpec.describe Flippable, type: :model do - let(:user_class) do - Class.new(ActiveRecord::Base) do - include Flipside::Flippable - end - end - let(:feature) { Feature.create!(name: "some_feature") } before do - stub_const("User", user_class) ActiveRecord::Base.connection.create_table :users, force: true do |t| t.string(:name) end @@ -19,17 +14,88 @@ module Flipside after do ActiveRecord::Base.connection.drop_table(:users, if_exists: true) + Flipside.flippables = [] + Flipside.send(:registered_entities).clear + Flipside.send(:registered_roles).clear end - it "removes the entities of a destroyed record" do - user = User.create!(name: "John Doe") - other_user = User.create!(name: "Jane Doe") - Entity.create!(feature:, flippable: user) - other_entity = Entity.create!(feature:, flippable: other_user) + def define_user(&block) + stub_const("User", Class.new(ActiveRecord::Base)) + User.include(Flippable) + User.class_eval(&block) if block + User + end + + describe ".flipside_entity" do + it "registers the class as an entity" do + Flipside.flippables = ["User"] + define_user { flipside_entity(display_as: :name) } - user.destroy + expect(Flipside.entity_classes).to eq(["User"]) + expect(Flipside.display_entity(User.new(name: "John Doe"))).to eq("John Doe") + end - expect(Entity.all).to eq([other_entity]) + it "removes the entities of a destroyed record" do + Flipside.flippables = ["User"] + define_user { flipside_entity } + user = User.create!(name: "John Doe") + other_user = User.create!(name: "Jane Doe") + Entity.create!(feature:, flippable: user) + other_entity = Entity.create!(feature:, flippable: other_user) + + user.destroy + + expect(Entity.all).to eq([other_entity]) + end + + it "raises when the class is not listed in Flipside.flippables" do + expect { define_user { flipside_entity } } + .to raise_error(Flipside::Error, "User must be listed in Flipside.flippables") + end + end + + describe ".flipside_role" do + it "registers the role" do + Flipside.flippables = ["User"] + define_user { flipside_role(:admin?, display_as: "Admin") } + + expect(Flipside.role_classes).to eq(["User"]) + expect(Flipside.search_role(class_name: "User", query: "adm").map(&:display_as)) + .to eq(["Admin"]) + end + end + + describe "loading listed classes" do + around do |example| + Dir.mktmpdir do |dir| + path = File.join(dir, "widget.rb") + File.write(path, <<~RUBY) + class Widget < ActiveRecord::Base + self.table_name = "users" + include Flipside::Flippable + flipside_entity display_as: :name + end + RUBY + Object.autoload(:Widget, path) + example.run + ensure + Object.send(:remove_const, :Widget) if Object.const_defined?(:Widget, false) + end + end + + it "loads a listed class that has not been loaded yet" do + Flipside.flippables = ["Widget"] + + expect(Object.autoload?(:Widget)).not_to be_nil + expect(Flipside.entity_classes).to eq(["Widget"]) + end + + it "raises when a listed class registers nothing" do + Flipside.flippables = ["User"] + define_user + + expect { Flipside.entity_classes }.to raise_error(Flipside::Error, /calls neither/) + end end end end From a3b10df2db4e393f97ccf88518d91f6d2a59048f Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 12:05:37 +0200 Subject: [PATCH 06/12] Cover the entities page with a deleted record The reported 500 came from rendering the entities page, which the display_entity specs do not reach. Request the page through the Roda app with an entity whose record is gone, and check that it renders with the Remove form. Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/flipside/web_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 spec/flipside/web_spec.rb diff --git a/spec/flipside/web_spec.rb b/spec/flipside/web_spec.rb new file mode 100644 index 0000000..fbfe16d --- /dev/null +++ b/spec/flipside/web_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rack/mock" + +module Flipside + RSpec.describe Web do + let(:user_class) { Class.new(ActiveRecord::Base) } + let(:app) { Rack::MockRequest.new(Web) } + + before do + stub_const("User", user_class) + ActiveRecord::Base.connection.create_table :users, force: true do |t| + t.string(:name) + end + Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + end + + after do + ActiveRecord::Base.connection.drop_table(:users, if_exists: true) + Flipside.send(:registered_entities).clear + end + + describe "GET /feature/:name/entities" do + it "lists an entity whose record has been deleted, with a way to remove it" do + feature = Feature.create!(name: "some_feature") + user = User.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: user) + user.delete + + response = app.get("/feature/some_feature/entities") + + expect(response.status).to eq(200) + expect(response.body).to include("User ##{user.id} (deleted)") + expect(response.body).to include(%(name="entity_id" value="#{entity.id}")) + end + end + end +end From 7e58c0cb738f7a06c153cec103525b4c0c6edc0b Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 12:08:29 +0200 Subject: [PATCH 07/12] Update headings --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a0995c9..6b13399 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ We can also check multiple records. `.enabled?` will return `true` if any of the Flipside.enabled? "MyFeature", user, company, location ``` -### Enabling features for specific records +#### Enabling features for specific records 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`: ```ruby @@ -130,7 +130,7 @@ Note: you probably want to wrap this inside a constraints block to provide some ![UI](/features.png) -### Configuration +## Configuration Flipside can be configured by calling some class methods on `Flipside` (see below). @@ -161,7 +161,7 @@ Flipside.enabled? :some_feature, Current.user Flipside.default_object = -> { [Current.user, Current.organization] } ``` -# Note: if we do pass in an argument, then `default_object ` will not be used: +#### Note: if we do pass in an argument, then `default_object ` will not be used: Flipside.enabled? :some_feature, Current.company # check current company instead of user. ``` @@ -177,7 +177,7 @@ Flipside.create_missing_features = true Flipside.default_object = -> { Current.user } ``` -#### Entities +### Entities Entities can be added to a feature by searching for records. @@ -233,7 +233,7 @@ Flipside.register_entity( ) ``` -#### Roles +### Roles Features can be enabled for certain roles, by searching for roles (by method name). From a43f6226c79525e73a1a5946755f0e5c9b42028a Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 14:01:05 +0200 Subject: [PATCH 08/12] Deprecate register_entity and register_role Flipside::Flippable is now the way to register entities and roles, so the initializer API is deprecated for removal in 1.0. Warnings go through a Flipside deprecator, which a Railtie hands to the host app on Rails 7.1+ so its deprecation settings apply to them. The macros register through add_registered_entity and add_registered_role instead, so only the deprecated entry points warn, and removing them later leaves the macros untouched. The README now documents the macros, registering a class the app cannot edit from to_prepare, and how to move off the deprecated methods. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++ README.md | 93 +++++++++++++++++++++----------- app/models/flipside/flippable.rb | 4 +- lib/flipside.rb | 6 +++ lib/flipside/config/entities.rb | 9 ++++ lib/flipside/config/roles.rb | 9 ++++ lib/flipside/railtie.rb | 9 ++++ spec/flipside/flippable_spec.rb | 10 ++++ spec/flipside/web_spec.rb | 2 +- spec/flipside_spec.rb | 46 +++++++++++++--- 10 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 lib/flipside/railtie.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 059da80..aebc383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ - Add `Flipside.prune_orphaned_entities` - Registering the same role twice no longer lists it twice +### Deprecated +- `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. + ## [0.3.4] - 2026-03-04 - Support an array of default objects diff --git a/README.md b/README.md index 6b13399..8e9bf70 100644 --- a/README.md +++ b/README.md @@ -177,34 +177,51 @@ Flipside.create_missing_features = true Flipside.default_object = -> { Current.user } ``` +### Registering from the model + +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 `flipside_entity` and/or `flipside_role`: +```ruby +class User < ApplicationRecord + include Flipside::Flippable + + flipside_entity search_by: :name, display_as: :name + flipside_role :admin? + flipside_role :awesome?, display_as: "Awesome users" +end +``` + +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): +```ruby +# config/initializers/flipside.rb +Flipside.flippables = %w[User] +``` + +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. + ### Entities Entities can be added to a feature by searching for records. ![Add an entity](/add_entity.png) -To make this work, some configuration is required. Use the class method `Flipside.register_entity` for this. +`flipside_entity` should be called in each class that may be used as a feature enabler. It also removes the entities of a record when it is destroyed. ```ruby -Flipside.register_entity( - class_name: "User", - search_by: :name, - display_as: :name, - identified_by: :id -) +class User < ApplicationRecord + include Flipside::Flippable + + flipside_entity search_by: :name, display_as: :name, identified_by: :id +end ``` -The `.register_entity` method should be called once for each class that may be used as a feature enabler. 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, e.g. `:name`, then entities with an exact match on the corresponding attribute are returned. I.e. `User.where(name: query)`. 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). 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 starting with _query_, something like this could be used. ```ruby -Flipside.register_entity( - class_name: "User", - search_by: ->(str) { User.where("lower(first_name) = :name OR lower(last_name) = :name or email LIKE :str", name: str.downcase, str: "#{str}%") }, -) - +flipside_entity search_by: ->(str) { + where("lower(first_name) = :name OR lower(last_name) = :name or email LIKE :str", name: str.downcase, str: "#{str}%") +} ``` 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. @@ -214,23 +231,19 @@ The `display_as` keyword argument, is used to configure how these entities show For example, given the following setup. Users will be displayed with first name and last name: ```ruby class User < ApplicationRecord + include Flipside::Flippable + + flipside_entity display_as: :name + def name [first_name, last_name].compact.map(&:capitalize).join(" ") end end - -Flipside.register_entity( - class_name: "User", - display_as: :name, -) ``` 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: ```ruby -Flipside.register_entity( - class_name: "User", - display_as: ->(user) { [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") } -) +flipside_entity display_as: ->(user) { [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") } ``` ### Roles @@ -239,32 +252,50 @@ Features can be enabled for certain roles, by searching for roles (by method nam ![Add a role](/add_role.png) -This is configured by calling the class method `Flipside.register_role` for each role to be added. -Note a role consists of a class and a corresponding instance method. +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. ```ruby -Flipside.register_role(class_name: "User", method_name: :admin?) -Flipside.register_role(class_name: "User", method_name: :awesome?) +class User < ApplicationRecord + include Flipside::Flippable + + flipside_role :admin? + flipside_role :awesome?, display_as: "Awesome users" +end ``` -### Registering from the model +### Classes you cannot edit -Instead of registering entities and roles in an initializer, a model can register itself by including `Flipside::Flippable`. List the class names in the initializer, so that Flipside can load them when the UI needs them (also when the app does not eager load): +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`: ```ruby # config/initializers/flipside.rb -Flipside.flippables = %w[User] +Flipside.flippables = %w[User OtherGem::Account] + +Rails.application.config.to_prepare do + OtherGem::Account.include(Flipside::Flippable) + OtherGem::Account.flipside_entity(display_as: :name) +end ``` +### Upgrading from `register_entity` and `register_role` + +`Flipside.register_entity` and `Flipside.register_role` are deprecated and will be removed in Flipside 1.0. Move each call into its model: ```ruby +# Before, in config/initializers/flipside.rb +Flipside.register_entity(class_name: "User", search_by: :name, display_as: :name) +Flipside.register_role(class_name: "User", method_name: :admin?) + +# After, in config/initializers/flipside.rb +Flipside.flippables = %w[User] + +# and in app/models/user.rb class User < ApplicationRecord include Flipside::Flippable flipside_entity search_by: :name, display_as: :name flipside_role :admin? - flipside_role :awesome?, display_as: "Awesome users" end ``` -`flipside_entity` takes the same options as `Flipside.register_entity`, and `flipside_role` those of `Flipside.register_role`. `flipside_entity` also removes the entities of a record when it is destroyed. 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. +In a Rails 7.1+ app the deprecation warnings follow the app's own deprecation settings (`Rails.application.deprecators[:flipside]`). ## Development diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index 72ac648..b8db001 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -20,13 +20,13 @@ def flipside_entity(search_by: nil, display_as: nil, identified_by: :id) as: :flippable, dependent: :delete_all - Flipside.register_entity(class_name: name, search_by:, display_as:, identified_by:) + Flipside.add_registered_entity(class_name: name, search_by:, display_as:, identified_by:) end def flipside_role(method_name, display_as: nil) ensure_listed_in_flippables! - Flipside.register_role(class_name: name, method_name:, display_as:) + Flipside.add_registered_role(class_name: name, method_name:, display_as:) end private diff --git a/lib/flipside.rb b/lib/flipside.rb index ddd7657..6267609 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_record" +require "active_support/deprecation" require "flipside/version" require "flipside/web" require "flipside/config/settings" @@ -9,6 +10,7 @@ require "flipside/config/flippables" require "models/flipside/feature" require "models/flipside/flippable" +require "flipside/railtie" if defined?(Rails::Railtie) module Flipside extend Config::Settings @@ -25,6 +27,10 @@ def initialize(name) end class << self + def deprecator + @deprecator ||= ActiveSupport::Deprecation.new("1.0", "Flipside") + end + def enabled?(name, *objects) feature = find_by(name:) return false unless feature diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index 2d6a6d3..d67d0b3 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -4,6 +4,15 @@ module Flipside module Config module Entities def register_entity(class_name:, search_by:, display_as:, identified_by: :id) + deprecator.warn( + "Flipside.register_entity is deprecated and will be removed in Flipside " \ + "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \ + "#{class_name}, call flipside_entity there and list it in Flipside.flippables." + ) + add_registered_entity(class_name:, search_by:, display_as:, identified_by:) + end + + def add_registered_entity(class_name:, search_by:, display_as:, identified_by: :id) # :nodoc: registered_entities[class_name.to_s] = RegisteredEntity.new( class_name:, search_by:, diff --git a/lib/flipside/config/roles.rb b/lib/flipside/config/roles.rb index 2744ad3..408b09d 100644 --- a/lib/flipside/config/roles.rb +++ b/lib/flipside/config/roles.rb @@ -4,6 +4,15 @@ module Flipside module Config module Roles def register_role(class_name:, method_name:, display_as: nil) + deprecator.warn( + "Flipside.register_role is deprecated and will be removed in Flipside " \ + "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \ + "#{class_name}, call flipside_role there and list it in Flipside.flippables." + ) + add_registered_role(class_name:, method_name:, display_as:) + end + + def add_registered_role(class_name:, method_name:, display_as: nil) # :nodoc: registered_roles[class_name.to_s] ||= {} registered_roles[class_name.to_s][method_name.to_s] = RegisteredRole.new( class_name:, diff --git a/lib/flipside/railtie.rb b/lib/flipside/railtie.rb new file mode 100644 index 0000000..5571427 --- /dev/null +++ b/lib/flipside/railtie.rb @@ -0,0 +1,9 @@ +module Flipside + # Hands Flipside's deprecator to the host app, so its deprecation settings + # (log, raise, silence) apply to Flipside's warnings too. + class Railtie < ::Rails::Railtie + initializer "flipside.deprecator" do |app| + app.deprecators[:flipside] = Flipside.deprecator if app.respond_to?(:deprecators) + end + end +end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index 3d3b10e..efb1431 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -65,6 +65,16 @@ def define_user(&block) end end + it "registers without deprecation warnings" do + expect(Flipside.deprecator).not_to receive(:warn) + Flipside.flippables = ["User"] + + define_user do + flipside_entity + flipside_role(:admin?) + end + end + describe "loading listed classes" do around do |example| Dir.mktmpdir do |dir| diff --git a/spec/flipside/web_spec.rb b/spec/flipside/web_spec.rb index fbfe16d..54a0109 100644 --- a/spec/flipside/web_spec.rb +++ b/spec/flipside/web_spec.rb @@ -12,7 +12,7 @@ module Flipside ActiveRecord::Base.connection.create_table :users, force: true do |t| t.string(:name) end - Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + Flipside.add_registered_entity(class_name: "User", search_by: nil, display_as: :name) end after do diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index 7135031..b9f11d1 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -284,9 +284,25 @@ def admin? Flipside.send(:registered_entities).clear end + it "is deprecated" do + expect(Flipside.deprecator) + .to receive(:warn) + .with(/register_entity is deprecated and will be removed in Flipside 1.0/) + + Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + + expect(Flipside.entity_classes).to eq(["User"]) + end + end + + describe ".entity_classes" do + after do + Flipside.send(:registered_entities).clear + end + it "can list entity classes" do - Flipside.register_entity(class_name: "Foo", search_by: nil, display_as: nil) - Flipside.register_entity(class_name: "Bar", search_by: nil, display_as: nil) + Flipside.add_registered_entity(class_name: "Foo", search_by: nil, display_as: nil) + Flipside.add_registered_entity(class_name: "Bar", search_by: nil, display_as: nil) expect(Flipside.entity_classes).to eq(["Foo", "Bar"]) end @@ -297,9 +313,25 @@ def admin? Flipside.send(:registered_roles).clear end - it "registers the same role only once" do + it "is deprecated" do + expect(Flipside.deprecator) + .to receive(:warn) + .with(/register_role is deprecated and will be removed in Flipside 1.0/) + Flipside.register_role(class_name: "User", method_name: :admin?) - Flipside.register_role(class_name: "User", method_name: :admin?, display_as: "Admin") + + expect(Flipside.role_classes).to eq(["User"]) + end + end + + describe ".add_registered_role" do + after do + Flipside.send(:registered_roles).clear + end + + it "registers the same role only once" do + Flipside.add_registered_role(class_name: "User", method_name: :admin?) + Flipside.add_registered_role(class_name: "User", method_name: :admin?, display_as: "Admin") results = Flipside.search_role(class_name: "User", query: "admin") @@ -313,14 +345,14 @@ def admin? end it "can list entity classes" do - Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + Flipside.add_registered_entity(class_name: "User", search_by: nil, display_as: :name) user = User.new(name: "John Doe") expect(Flipside.display_entity(user)).to eq("John Doe") end it "displays the flippable of an entity" do - Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + Flipside.add_registered_entity(class_name: "User", search_by: nil, display_as: :name) feature = Feature.create!(name: "some_feature") user = User.create!(name: "John Doe") entity = Entity.create!(feature:, flippable: user) @@ -329,7 +361,7 @@ def admin? end it "displays an entity whose flippable has been deleted" do - Flipside.register_entity(class_name: "User", search_by: nil, display_as: :name) + Flipside.add_registered_entity(class_name: "User", search_by: nil, display_as: :name) feature = Feature.create!(name: "some_feature") user = User.create!(name: "John Doe") entity = Entity.create!(feature:, flippable: user) From 232a101bead28ae03d9dbc6360e8a3b706b636c2 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 14:32:15 +0200 Subject: [PATCH 09/12] Display STI subclass entities by their own class Rails stores the base class in flippable_type, so an entity for an Admin < User record was looked up as "User" and shown as "User #id" when only Admin calls flipside_entity. Look up the record's class instead, the same key display_flippable uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/flipside/config/entities.rb | 2 +- spec/flipside_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index d67d0b3..377b083 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -47,7 +47,7 @@ def display_entity(entity) if record.nil? "#{label} (deleted)" - elsif registered_entities.key?(entity.flippable_type) + elsif registered_entities.key?(record.class.to_s) display_flippable(record) else label diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index b9f11d1..6ad9e60 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -370,6 +370,18 @@ def admin? expect(Flipside.display_entity(entity.reload)).to eq("User ##{user.id} (deleted)") end + it "displays an entity whose flippable is an STI subclass" do + ActiveRecord::Base.connection.add_column(:users, :type, :string) + User.reset_column_information + stub_const("Admin", Class.new(User)) + Flipside.add_registered_entity(class_name: "Admin", search_by: nil, display_as: :name) + feature = Feature.create!(name: "some_feature") + admin = Admin.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: admin) + + expect(Flipside.display_entity(entity.reload)).to eq("John Doe") + end + it "displays an entity whose class is no longer registered" do feature = Feature.create!(name: "some_feature") user = User.create!(name: "John Doe") From 8cc7e8d1c4772cbc76586037293b458d8ccb5f6d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 14:32:16 +0200 Subject: [PATCH 10/12] Ignore default scopes when pruning orphaned entities A soft-deleting default scope hid records from the existence check, so prune_orphaned_entities deleted their entities for good and a restored record came back without its features. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/flipside.rb | 1 + spec/flipside_spec.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/flipside.rb b/lib/flipside.rb index 6267609..1031118 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -73,6 +73,7 @@ def prune_orphaned_entities next entities.delete_all unless klass existing = klass + .unscoped .where(klass.primary_key => entities.pluck(:flippable_id)) .pluck(klass.primary_key) entities.where.not(flippable_id: existing).delete_all diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index 6ad9e60..c1a25ea 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -277,6 +277,20 @@ def admin? expect(Flipside.prune_orphaned_entities).to eq(1) expect(Entity.count).to eq(0) end + + it "keeps entities whose record is hidden by a default scope" do + stub_const("SoftDeletedUser", Class.new(ActiveRecord::Base) do + self.table_name = "users" + default_scope { where.not(name: "deleted") } + end) + feature = Feature.create!(name: "some_feature") + user = SoftDeletedUser.create!(name: "John Doe") + entity = Entity.create!(feature:, flippable: user) + user.update!(name: "deleted") + + expect(Flipside.prune_orphaned_entities).to eq(0) + expect(Entity.all).to eq([entity]) + end end describe ".register_entity" do From 84632ca1958e42507859c73cf89fa70ac757188d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 14:32:16 +0200 Subject: [PATCH 11/12] Display entities whose class no longer exists entity.flippable raises NameError when flippable_type no longer names a class, so the entities page still failed for the rows prune_orphaned_entities deletes for that reason. Show them as deleted. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/flipside/config/entities.rb | 7 ++++--- spec/flipside_spec.rb | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index 377b083..efabf2d 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -37,12 +37,13 @@ def find_entity(class_name:, identifier:) end # Accepts either a flippable record or a Flipside::Entity join row. Given - # a join row, it still renders when the record has been deleted or its - # class is no longer registered, so the UI can list it for removal. + # a join row, it still renders when the record or its class has been + # deleted, or the class is no longer registered, so the UI can list it + # for removal. def display_entity(entity) return display_flippable(entity) unless entity.is_a?(Flipside::Entity) - record = entity.flippable + record = entity.flippable if entity.flippable_type.safe_constantize label = "#{entity.flippable_type} ##{entity.flippable_id}" if record.nil? diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index c1a25ea..ebad6b7 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -403,6 +403,13 @@ def admin? expect(Flipside.display_entity(entity)).to eq("User ##{user.id}") end + + it "displays an entity whose class no longer exists" do + feature = Feature.create!(name: "some_feature") + entity = Entity.create!(feature:, flippable_type: "Removed", flippable_id: 1) + + expect(Flipside.display_entity(entity)).to eq("Removed #1 (deleted)") + end end end end From 5b5d68ffcc2496e74ec7c15b493f08bbd2d52740 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 14:32:16 +0200 Subject: [PATCH 12/12] Check Flipside.flippables when loading, not in the macros The macros raised unless their class was already listed, but a model referenced from an initializer that runs before config/initializers/flipside.rb loads while the list is still empty, which failed boot for a correctly configured app. The macros now record their class, and load_flippables raises for any that is not listed. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/flipside/flippable.rb | 13 +++++++------ lib/flipside/config/flippables.rb | 13 +++++++++++++ spec/flipside/flippable_spec.rb | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index b8db001..24e8139 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -5,7 +5,9 @@ module Flipside # Lets a model register itself with Flipside. The class must also be listed in - # Flipside.flippables, so that Flipside can load it when the UI needs it. + # Flipside.flippables, so that Flipside can load it when the UI needs it. The + # listing is checked when Flipside loads the classes rather than here, since + # a model may be loaded before the initializer that sets Flipside.flippables. module Flippable extend ActiveSupport::Concern @@ -13,7 +15,7 @@ module Flippable # Registers the class as an entity and removes its Flipside::Entity rows # when a record is destroyed. def flipside_entity(search_by: nil, display_as: nil, identified_by: :id) - ensure_listed_in_flippables! + register_flippable! has_many :flipside_entities, class_name: "Flipside::Entity", @@ -24,18 +26,17 @@ def flipside_entity(search_by: nil, display_as: nil, identified_by: :id) end def flipside_role(method_name, display_as: nil) - ensure_listed_in_flippables! + register_flippable! Flipside.add_registered_role(class_name: name, method_name:, display_as:) end private - def ensure_listed_in_flippables! + def register_flippable! raise Flipside::Error, "Flipside macros need a named class" if name.nil? - return if Flipside.flippables.include?(name) - raise Flipside::Error, "#{name} must be listed in Flipside.flippables" + Flipside.register_flippable(name) end end end diff --git a/lib/flipside/config/flippables.rb b/lib/flipside/config/flippables.rb index ed5c27a..94bc8c7 100644 --- a/lib/flipside/config/flippables.rb +++ b/lib/flipside/config/flippables.rb @@ -10,6 +10,10 @@ def flippables @flippables ||= [] end + def register_flippable(class_name) # :nodoc: + registered_flippables << class_name.to_s + end + private def load_flippables @@ -20,6 +24,15 @@ def load_flippables raise Error, "#{name} is listed in Flipside.flippables but calls neither " \ "flipside_entity nor flipside_role" end + + unlisted = registered_flippables.to_a - flippables + return if unlisted.empty? + + raise Error, "#{unlisted.join(", ")} must be listed in Flipside.flippables" + end + + def registered_flippables + @registered_flippables ||= Set.new end end end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index efb1431..0340135 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -17,6 +17,7 @@ module Flipside Flipside.flippables = [] Flipside.send(:registered_entities).clear Flipside.send(:registered_roles).clear + Flipside.send(:registered_flippables).clear end def define_user(&block) @@ -48,10 +49,19 @@ def define_user(&block) expect(Entity.all).to eq([other_entity]) end - it "raises when the class is not listed in Flipside.flippables" do - expect { define_user { flipside_entity } } + it "raises on use when the class is not listed in Flipside.flippables" do + define_user { flipside_entity } + + expect { Flipside.entity_classes } .to raise_error(Flipside::Error, "User must be listed in Flipside.flippables") end + + it "can be called before Flipside.flippables is set" do + define_user { flipside_entity(display_as: :name) } + Flipside.flippables = ["User"] + + expect(Flipside.entity_classes).to eq(["User"]) + end end describe ".flipside_role" do