From c4617a1f227b469206d0d15101e2dbd76745d65d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 15:28:27 +0200 Subject: [PATCH 1/4] Display STI subclasses through their base class's registration With flipside_entity in User, an Admin < User record was looked up as "Admin", so its entity showed as "User #id" and display_entity(admin) raised KeyError. Look the registration up through the class's ancestors, so a subclass uses the closest registered class and its own registration still wins when it has one. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/flipside/config/entities.rb | 24 +++++++++++++++++++----- spec/flipside_spec.rb | 13 +++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index efabf2d..f8b0791 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -48,8 +48,8 @@ def display_entity(entity) if record.nil? "#{label} (deleted)" - elsif registered_entities.key?(record.class.to_s) - display_flippable(record) + elsif (registration = registration_for(record.class)) + registration.display(record) else label end @@ -58,9 +58,23 @@ def display_entity(entity) private def display_flippable(record) - registered_entities - .fetch(record.class.to_s) - .display(record) + registration = registration_for(record.class) + raise KeyError, "#{record.class} is not registered with Flipside" unless registration + + registration.display(record) + end + + # The registration of the class or its closest registered superclass, so + # an STI subclass is displayed through the base class's registration + # unless it has one of its own. + def registration_for(klass) + klass.ancestors.each do |ancestor| + next unless ancestor.is_a?(Class) + + registration = registered_entities[ancestor.name] + return registration if registration + end + nil end def registered_entities diff --git a/spec/flipside_spec.rb b/spec/flipside_spec.rb index ebad6b7..2f153e6 100644 --- a/spec/flipside_spec.rb +++ b/spec/flipside_spec.rb @@ -384,6 +384,19 @@ def admin? expect(Flipside.display_entity(entity.reload)).to eq("User ##{user.id} (deleted)") end + it "displays an STI subclass through the registration of its base class" 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: "User", 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") + expect(Flipside.display_entity(admin)).to eq("John Doe") + end + it "displays an entity whose flippable is an STI subclass" do ActiveRecord::Base.connection.add_column(:users, :type, :string) User.reset_column_information From 984eb513297fd401eee12f523ab06ba2db902312 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 15:30:10 +0200 Subject: [PATCH 2/4] Verify Flipside.flippables at boot when the app eager loads Checking the list only when the UI reads the registry meant an unlisted class using the macros, or a listed class registering nothing, passed boot and CI and failed on the first visit to the UI. The Railtie now runs the same check in after_initialize when the app eager loads. Rails eager loads before running after_initialize (6.1 through 8.1), so every class using the macros has registered by then. Without eager loading the check is left to the UI, since it would only see whatever is loaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/flipside/config/flippables.rb | 7 +++++++ lib/flipside/railtie.rb | 13 +++++++++++-- spec/flipside/flippable_spec.rb | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/flipside/config/flippables.rb b/lib/flipside/config/flippables.rb index 94bc8c7..f7fe939 100644 --- a/lib/flipside/config/flippables.rb +++ b/lib/flipside/config/flippables.rb @@ -14,6 +14,13 @@ def register_flippable(class_name) # :nodoc: registered_flippables << class_name.to_s end + # Raises unless Flipside.flippables and the classes calling the + # Flipside::Flippable macros agree. Runs whenever the UI reads the + # registry, and at boot in an app that eager loads (see Railtie). + def verify_flippables! # :nodoc: + load_flippables + end + private def load_flippables diff --git a/lib/flipside/railtie.rb b/lib/flipside/railtie.rb index 5571427..d548ca3 100644 --- a/lib/flipside/railtie.rb +++ b/lib/flipside/railtie.rb @@ -1,9 +1,18 @@ module Flipside - # Hands Flipside's deprecator to the host app, so its deprecation settings - # (log, raise, silence) apply to Flipside's warnings too. + # Wires Flipside into a Rails app. Only loaded when Rails is present. class Railtie < ::Rails::Railtie + # Hands Flipside's deprecator to the app, so its deprecation settings (log, + # raise, silence) apply to Flipside's warnings too. initializer "flipside.deprecator" do |app| app.deprecators[:flipside] = Flipside.deprecator if app.respond_to?(:deprecators) end + + # Eager loading runs before after_initialize, so every class calling the + # macros has registered by now. Checking here fails boot, and so CI, for a + # misconfigured list instead of the first visit to the UI. Without eager + # loading the check would only see whatever happens to be loaded. + config.after_initialize do |app| + Flipside.verify_flippables! if app.config.eager_load + end end end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index 0340135..e1759b3 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -75,6 +75,22 @@ def define_user(&block) end end + describe "Flipside.verify_flippables!" do + it "passes when the list matches the classes using the macros" do + Flipside.flippables = ["User"] + define_user { flipside_role(:admin?) } + + expect { Flipside.verify_flippables! }.not_to raise_error + end + + it "raises for a class using the macros that is not listed" do + define_user { flipside_role(:admin?) } + + expect { Flipside.verify_flippables! } + .to raise_error(Flipside::Error, "User must be listed in Flipside.flippables") + end + end + it "registers without deprecation warnings" do expect(Flipside.deprecator).not_to receive(:warn) Flipside.flippables = ["User"] From acbe75b8c3380c93f03156539d1681c0f8768113 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 15:30:23 +0200 Subject: [PATCH 3/4] Note that soft deletes with destroy callbacks remove entities flipside_entity's cleanup is a dependent: :delete_all association, which runs on any destroy that runs callbacks, soft deletes included. Say so, since a restored record comes back without its features. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e9bf70..e8855aa 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Entities can be added to a feature by searching for records. ![Add an entity](/add_entity.png) -`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. +`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. 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. ```ruby class User < ApplicationRecord include Flipside::Flippable From 1a4ade09f4946a19b3f6f3a49123ae92df006504 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Fri, 25 Sep 2026 15:34:17 +0200 Subject: [PATCH 4/4] Bump version to 0.4.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++- Gemfile.lock | 2 +- lib/flipside/version.rb | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aebc383..b71c2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ -## [Unreleased] +## [0.4.0] - 2026-09-25 - Show entities whose record has been deleted instead of raising in the UI - 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 +- Display STI subclass entities through their own or their base class's registration +- Verify `Flipside.flippables` at boot when a Rails app eager loads ### 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. diff --git a/Gemfile.lock b/Gemfile.lock index 99ca741..51bc284 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - flipside (0.3.4) + flipside (0.4.0) activerecord (>= 6.0) roda tilt diff --git a/lib/flipside/version.rb b/lib/flipside/version.rb index ba0b212..93128bc 100644 --- a/lib/flipside/version.rb +++ b/lib/flipside/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Flipside - VERSION = "0.3.4" + VERSION = "0.4.0" end