From 8af789ae0217efa5a1c8a97c8fbcd168d1eadae7 Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Wed, 7 Oct 2026 22:49:51 +0200 Subject: [PATCH 1/4] Add flipside_search_by, flipside_display_as and flipside_identified_by Each takes over one option of flipside_entity and registers the class as an entity on its own, so flipside_entity is only needed to register with the defaults. flipside_search_by and flipside_display_as take a block or a method name; for search that is a class method such as a scope. The options of flipside_entity are deprecated. Also clear the connection's statement cache when the RegisteredEntity spec recreates the users table, which made it fail in some orders. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 + README.md | 61 ++++++---- app/models/flipside/flippable.rb | 116 +++++++++++++++++-- spec/flipside/flippable_spec.rb | 144 +++++++++++++++++++++++- spec/flipside/registered_entity_spec.rb | 1 + 5 files changed, 298 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b71c2c2..4001a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] +- 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 + +### Deprecated +- 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. + ## [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 diff --git a/README.md b/README.md index e8855aa..da1e5df 100644 --- a/README.md +++ b/README.md @@ -97,7 +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. A model registered with `flipside_entity` (see [Registering from the model](#registering-from-the-model)) removes its entities when a record is destroyed. +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. Entities left behind by records deleted without callbacks (or before the concern was added) can be removed with: ```ruby @@ -179,12 +179,13 @@ 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`: +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`) and/or `flipside_role`: ```ruby class User < ApplicationRecord include Flipside::Flippable - flipside_entity search_by: :name, display_as: :name + flipside_search_by { |str| where(name: str) } + flipside_display_as :name flipside_role :admin? flipside_role :awesome?, display_as: "Awesome users" end @@ -204,36 +205,49 @@ 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. 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. +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. ```ruby class User < ApplicationRecord include Flipside::Flippable - flipside_entity search_by: :name, display_as: :name, identified_by: :id + flipside_entity end ``` -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). +`flipside_identified_by` sets the column used as primary key for the corresponding table. This defaults to `:id` and typically does not need to be changed. +Currently composite keys are not supported. +```ruby +flipside_identified_by :uuid +``` + +`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). 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_entity search_by: ->(str) { - where("lower(first_name) = :name OR lower(last_name) = :name or email LIKE :str", name: str.downcase, str: "#{str}%") -} +flipside_search_by do |str| + where( + "lower(first_name) = :name OR lower(last_name) = :name OR email LIKE :str", + name: str.downcase, + str: "#{str}%" + ) +end ``` -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. -Currently composite keys are not supported. +Instead of a block, it may be given the name of a class method (e.g. a scope), which is called with the search string: +```ruby +scope :lookup, ->(str) { where(email: str) } +flipside_search_by :lookup +``` + +Without `flipside_search_by`, records are found by an exact match on `identified_by`. -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. +`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. 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 + flipside_display_as :name def name [first_name, last_name].compact.map(&:capitalize).join(" ") @@ -241,11 +255,19 @@ class User < ApplicationRecord end ``` -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: +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: ```ruby -flipside_entity display_as: ->(user) { [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") } +flipside_display_as do |user| + [user.first_name, user.last_name].compact.map(&:capitalize).join(" ") +end ``` +Without `flipside_display_as`, entities are displayed by their `identified_by` value. + +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. + +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. Note that a `Symbol` given to `search_by:` matched that attribute exactly, whereas a method name given to `flipside_search_by` names a class method, so `search_by: :name` becomes `flipside_search_by { |str| where(name: str) }`. + ### Roles Features can be enabled for certain roles, by searching for roles (by method name). @@ -271,7 +293,7 @@ Flipside.flippables = %w[User OtherGem::Account] Rails.application.config.to_prepare do OtherGem::Account.include(Flipside::Flippable) - OtherGem::Account.flipside_entity(display_as: :name) + OtherGem::Account.flipside_display_as(:name) end ``` @@ -290,7 +312,8 @@ Flipside.flippables = %w[User] class User < ApplicationRecord include Flipside::Flippable - flipside_entity search_by: :name, display_as: :name + flipside_search_by { |str| where(name: str) } + flipside_display_as :name flipside_role :admin? end ``` diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index 24e8139..698acbd 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/concern" +require "active_support/core_ext/class/attribute" require "models/flipside/entity" module Flipside @@ -11,18 +12,43 @@ module Flipside module Flippable extend ActiveSupport::Concern + included do + class_attribute :_flipside_search_by, :_flipside_display_as, :_flipside_identified_by, + instance_accessor: false, instance_predicate: false + end + 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) - register_flippable! + # when a record is destroyed. Calling flipside_search_by, + # flipside_display_as or flipside_identified_by does the same, so this is + # only needed to register with the defaults. + def flipside_entity(search_by: nil, display_as: nil, identified_by: nil) + register_flipside_entity! + deprecated_entity_options(search_by:, display_as:, identified_by:) + end - has_many :flipside_entities, - class_name: "Flipside::Entity", - as: :flippable, - dependent: :delete_all + # Sets the column that identifies an entity, :id by default. Registers the + # class as an entity, as flipside_entity does. + def flipside_identified_by(column) + self._flipside_identified_by = column.to_sym + register_flipside_entity! + end + + # Sets how entities are found from a search in the UI, either with a block + # run in the context of the class or the name of a class method (e.g. a + # scope). Either is given the search string and returns the matching + # records. Registers the class as an entity, as flipside_entity does. + def flipside_search_by(method_name = nil, &block) + self._flipside_search_by = method_or_block(method_name, block) + register_flipside_entity! + end - Flipside.add_registered_entity(class_name: name, search_by:, display_as:, identified_by:) + # Sets how entities are displayed in the UI, either with a block given the + # record or the name of an instance method. Either returns a string. + # Registers the class as an entity, as flipside_entity does. + def flipside_display_as(method_name = nil, &block) + self._flipside_display_as = method_or_block(method_name, block) + register_flipside_entity! end def flipside_role(method_name, display_as: nil) @@ -38,6 +64,80 @@ def register_flippable! Flipside.register_flippable(name) end + + # Safe to call more than once. The registration is replaced each time, so + # that it picks up identified_by whichever order the macros are called in. + def register_flipside_entity! + register_flippable! + + unless @flipside_entity_registered + has_many :flipside_entities, + class_name: "Flipside::Entity", + as: :flippable, + dependent: :delete_all + @flipside_entity_registered = true + end + + Flipside.add_registered_entity( + class_name: name, + search_by: ->(query) { flipside_search(query) }, + display_as: ->(entity) { flipside_display(entity) }, + identified_by: flipside_identifier_column + ) + end + + def flipside_identifier_column + _flipside_identified_by || :id + end + + def method_or_block(method_name, block) + return method_name.to_sym if method_name && !block + return block if block && !method_name + + raise ArgumentError, "pass either a method name or a block" + end + + def flipside_search(query) + case _flipside_search_by + when Symbol then public_send(_flipside_search_by, query) + when Proc then instance_exec(query, &_flipside_search_by) + else where(flipside_identifier_column => query) + end + end + + def flipside_display(entity) + case _flipside_display_as + when Symbol then entity.public_send(_flipside_display_as) + when Proc then _flipside_display_as.call(entity) + else entity.public_send(flipside_identifier_column) + end + end + + def deprecated_entity_options(search_by:, display_as:, identified_by:) + return if search_by.nil? && display_as.nil? && identified_by.nil? + + Flipside.deprecator.warn( + "The search_by, display_as and identified_by options of flipside_entity are " \ + "deprecated and will be removed in Flipside #{Flipside.deprecator.deprecation_horizon}. " \ + "Use flipside_search_by, flipside_display_as and flipside_identified_by in " \ + "#{name} instead. Note that " \ + "a method name given to flipside_search_by names a class method or scope, so " \ + "search_by: :name becomes flipside_search_by { |query| where(name: query) }." + ) + + flipside_identified_by(identified_by) if identified_by + + case search_by + when Symbol then flipside_search_by { |query| where(search_by => query) } + when Proc then flipside_search_by(&search_by) + end + + case display_as + when Symbol then flipside_display_as(display_as) + when Proc then flipside_display_as(&display_as) + when String then flipside_display_as { display_as } + end + end end end end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index e1759b3..f0b13a2 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -30,7 +30,10 @@ def define_user(&block) describe ".flipside_entity" do it "registers the class as an entity" do Flipside.flippables = ["User"] - define_user { flipside_entity(display_as: :name) } + define_user do + flipside_entity + flipside_display_as :name + end expect(Flipside.entity_classes).to eq(["User"]) expect(Flipside.display_entity(User.new(name: "John Doe"))).to eq("John Doe") @@ -57,13 +60,148 @@ def define_user(&block) end it "can be called before Flipside.flippables is set" do - define_user { flipside_entity(display_as: :name) } + define_user { flipside_entity } Flipside.flippables = ["User"] expect(Flipside.entity_classes).to eq(["User"]) end end + describe ".flipside_search_by" do + before { Flipside.flippables = ["User"] } + + let!(:john) { define_user { flipside_entity }.create!(name: "John Doe") } + let!(:jane) { User.create!(name: "Jane Doe") } + + def search(query) + Flipside.search_entity(class_name: "User", query:).map(&:object) + end + + it "searches by id by default" do + expect(search(jane.id)).to eq([jane]) + end + + it "searches with a block" do + User.flipside_search_by { |str| where("name LIKE ?", "#{str}%") } + + expect(search("Ja")).to eq([jane]) + end + + it "searches with a class method" do + User.class_eval do + scope :named, ->(str) { where(name: str) } + flipside_search_by :named + end + + expect(search("John Doe")).to eq([john]) + end + + it "registers the class as an entity without flipside_entity" do + define_user { flipside_search_by { |str| where(name: str) } } + jim = User.create!(name: "Jim Doe") + Entity.create!(feature:, flippable: jim) + + expect(Flipside.entity_classes).to eq(["User"]) + expect(search("Jim Doe")).to eq([jim]) + expect { jim.destroy }.to change(Entity, :count).by(-1) + end + + it "keeps identified_by, whichever order the macros are called in" do + define_user do + flipside_search_by { |str| where(name: str) } + flipside_identified_by :name + flipside_display_as :name + flipside_entity + end + User.create!(name: "Jim Doe") + + expect(Flipside.search_entity(class_name: "User", query: "Jim Doe").map(&:identifier)) + .to eq(["Jim Doe"]) + end + + it "requires either a method name or a block" do + expect { User.flipside_search_by }.to raise_error(ArgumentError) + expect { User.flipside_search_by(:named) { nil } }.to raise_error(ArgumentError) + end + end + + describe ".flipside_identified_by" do + before { Flipside.flippables = ["User"] } + + it "identifies entities by the column" do + define_user { flipside_identified_by :name } + john = User.create!(name: "John Doe") + + expect(Flipside.entity_classes).to eq(["User"]) + expect(Flipside.search_entity(class_name: "User", query: "John Doe").map(&:identifier)) + .to eq(["John Doe"]) + expect(Flipside.find_entity(class_name: "User", identifier: "John Doe")).to eq(john) + expect(Flipside.display_entity(john)).to eq("John Doe") + end + end + + describe ".flipside_display_as" do + before { Flipside.flippables = ["User"] } + + def display(user) + Flipside.display_entity(user) + end + + it "displays the id by default" do + define_user { flipside_entity } + + expect(display(User.new(id: 5, name: "John Doe"))).to eq(5) + end + + it "displays with a block" do + define_user { flipside_display_as { |user| "#{user.name} (#{user.id})" } } + + expect(display(User.new(id: 5, name: "John Doe"))).to eq("John Doe (5)") + end + + it "displays with an instance method" do + define_user { flipside_display_as :name } + + expect(display(User.new(name: "John Doe"))).to eq("John Doe") + end + + it "is inherited by STI subclasses" do + define_user { flipside_display_as :name } + stub_const("Admin", Class.new(User)) + + expect(display(Admin.new(name: "John Doe"))).to eq("John Doe") + end + + it "registers an STI subclass of its own" do + Flipside.flippables = ["User", "Admin"] + define_user { flipside_display_as :name } + stub_const("Admin", Class.new(User)) + Admin.flipside_display_as { |user| "Admin #{user.name}" } + + expect(Flipside.entity_classes).to eq(["User", "Admin"]) + expect(display(User.new(name: "John Doe"))).to eq("John Doe") + expect(display(Admin.new(name: "John Doe"))).to eq("Admin John Doe") + end + end + + describe "deprecated flipside_entity options" do + before { Flipside.flippables = ["User"] } + + it "warns and still applies them" do + expect(Flipside.deprecator).to receive(:warn).with(/flipside_identified_by/) + define_user do + flipside_entity search_by: :name, display_as: ->(user) { user.name.upcase }, identified_by: :name + end + john = User.create!(name: "John Doe") + + results = Flipside.search_entity(class_name: "User", query: "John Doe") + + expect(results.map(&:object)).to eq([john]) + expect(results.map(&:display_as)).to eq(["JOHN DOE"]) + expect(results.map(&:identifier)).to eq(["John Doe"]) + end + end + describe ".flipside_role" do it "registers the role" do Flipside.flippables = ["User"] @@ -109,7 +247,7 @@ def define_user(&block) class Widget < ActiveRecord::Base self.table_name = "users" include Flipside::Flippable - flipside_entity display_as: :name + flipside_entity end RUBY Object.autoload(:Widget, path) diff --git a/spec/flipside/registered_entity_spec.rb b/spec/flipside/registered_entity_spec.rb index a3a0147..615d3cd 100644 --- a/spec/flipside/registered_entity_spec.rb +++ b/spec/flipside/registered_entity_spec.rb @@ -15,6 +15,7 @@ module Flipside t.string(:name) t.string(:email) end + ActiveRecord::Base.connection.clear_cache! end after do From 5015f147c55c5eae1cde5193308de89b2574490f Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Wed, 7 Oct 2026 22:49:52 +0200 Subject: [PATCH 2/4] Let RegisteredEntity interpret search_by and display_as The Flippable macros now set these on the class's registration instead of keeping their own copies, so the Symbol or Proc handling lives in one place. A Symbol given as search_by now names a class method; the deprecated options turn a column name into a search block. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/flipside/flippable.rb | 93 ++++++++---------------- lib/flipside/config/entities.rb | 3 +- lib/flipside/config/registered_entity.rb | 62 +++++++++------- spec/flipside/flippable_spec.rb | 30 ++++++++ spec/flipside/registered_entity_spec.rb | 30 +++++++- 5 files changed, 123 insertions(+), 95 deletions(-) diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index 698acbd..5e2d430 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -13,8 +13,9 @@ module Flippable extend ActiveSupport::Concern included do - class_attribute :_flipside_search_by, :_flipside_display_as, :_flipside_identified_by, - instance_accessor: false, instance_predicate: false + # The class's Flipside::RegisteredEntity. Being a class attribute, it is + # inherited by STI subclasses and starts out empty in a reloaded class. + class_attribute :_flipside_registration, instance_accessor: false, instance_predicate: false end class_methods do @@ -23,32 +24,29 @@ module Flippable # flipside_display_as or flipside_identified_by does the same, so this is # only needed to register with the defaults. def flipside_entity(search_by: nil, display_as: nil, identified_by: nil) - register_flipside_entity! + flipside_registration deprecated_entity_options(search_by:, display_as:, identified_by:) end - # Sets the column that identifies an entity, :id by default. Registers the - # class as an entity, as flipside_entity does. - def flipside_identified_by(column) - self._flipside_identified_by = column.to_sym - register_flipside_entity! - end - # Sets how entities are found from a search in the UI, either with a block # run in the context of the class or the name of a class method (e.g. a # scope). Either is given the search string and returns the matching # records. Registers the class as an entity, as flipside_entity does. def flipside_search_by(method_name = nil, &block) - self._flipside_search_by = method_or_block(method_name, block) - register_flipside_entity! + flipside_registration.search_by = method_or_block(method_name, block) end # Sets how entities are displayed in the UI, either with a block given the # record or the name of an instance method. Either returns a string. # Registers the class as an entity, as flipside_entity does. def flipside_display_as(method_name = nil, &block) - self._flipside_display_as = method_or_block(method_name, block) - register_flipside_entity! + flipside_registration.display_as = method_or_block(method_name, block) + end + + # Sets the column that identifies an entity, :id by default. Registers the + # class as an entity, as flipside_entity does. + def flipside_identified_by(column) + flipside_registration.identified_by = column.to_sym end def flipside_role(method_name, display_as: nil) @@ -65,31 +63,24 @@ def register_flippable! Flipside.register_flippable(name) end - # Safe to call more than once. The registration is replaced each time, so - # that it picks up identified_by whichever order the macros are called in. - def register_flipside_entity! + # The registration this class owns, created on first use. An STI subclass + # starts from a copy of the settings it inherits. + def flipside_registration + return _flipside_registration if _flipside_registration&.class_name == name + register_flippable! - unless @flipside_entity_registered - has_many :flipside_entities, - class_name: "Flipside::Entity", - as: :flippable, - dependent: :delete_all - @flipside_entity_registered = true - end + has_many :flipside_entities, + class_name: "Flipside::Entity", + as: :flippable, + dependent: :delete_all - Flipside.add_registered_entity( + self._flipside_registration = Flipside.add_registered_entity( class_name: name, - search_by: ->(query) { flipside_search(query) }, - display_as: ->(entity) { flipside_display(entity) }, - identified_by: flipside_identifier_column + **(_flipside_registration&.options || {}) ) end - def flipside_identifier_column - _flipside_identified_by || :id - end - def method_or_block(method_name, block) return method_name.to_sym if method_name && !block return block if block && !method_name @@ -97,22 +88,6 @@ def method_or_block(method_name, block) raise ArgumentError, "pass either a method name or a block" end - def flipside_search(query) - case _flipside_search_by - when Symbol then public_send(_flipside_search_by, query) - when Proc then instance_exec(query, &_flipside_search_by) - else where(flipside_identifier_column => query) - end - end - - def flipside_display(entity) - case _flipside_display_as - when Symbol then entity.public_send(_flipside_display_as) - when Proc then _flipside_display_as.call(entity) - else entity.public_send(flipside_identifier_column) - end - end - def deprecated_entity_options(search_by:, display_as:, identified_by:) return if search_by.nil? && display_as.nil? && identified_by.nil? @@ -120,23 +95,15 @@ def deprecated_entity_options(search_by:, display_as:, identified_by:) "The search_by, display_as and identified_by options of flipside_entity are " \ "deprecated and will be removed in Flipside #{Flipside.deprecator.deprecation_horizon}. " \ "Use flipside_search_by, flipside_display_as and flipside_identified_by in " \ - "#{name} instead. Note that " \ - "a method name given to flipside_search_by names a class method or scope, so " \ - "search_by: :name becomes flipside_search_by { |query| where(name: query) }." + "#{name} instead. Note that a method name given to flipside_search_by names a " \ + "class method or scope, so search_by: :name becomes " \ + "flipside_search_by { |query| where(name: query) }." ) - flipside_identified_by(identified_by) if identified_by - - case search_by - when Symbol then flipside_search_by { |query| where(search_by => query) } - when Proc then flipside_search_by(&search_by) - end - - case display_as - when Symbol then flipside_display_as(display_as) - when Proc then flipside_display_as(&display_as) - when String then flipside_display_as { display_as } - end + registration = flipside_registration + registration.search_by = RegisteredEntity.column_search(search_by) if search_by + registration.display_as = display_as if display_as + registration.identified_by = identified_by if identified_by end end end diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index f8b0791..f3f7d0a 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -9,10 +9,11 @@ def register_entity(class_name:, search_by:, display_as:, identified_by: :id) "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \ "#{class_name}, call flipside_entity there and list it in Flipside.flippables." ) + search_by = RegisteredEntity.column_search(search_by) 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: + def add_registered_entity(class_name:, search_by: nil, display_as: nil, identified_by: :id) # :nodoc: registered_entities[class_name.to_s] = RegisteredEntity.new( class_name:, search_by:, diff --git a/lib/flipside/config/registered_entity.rb b/lib/flipside/config/registered_entity.rb index ce05452..b2958c2 100644 --- a/lib/flipside/config/registered_entity.rb +++ b/lib/flipside/config/registered_entity.rb @@ -2,7 +2,16 @@ module Flipside class RegisteredEntity - attr_reader :class_name, :search_by, :display_as, :identified_by + attr_reader :class_name + attr_accessor :search_by, :display_as, :identified_by + + # Turns a column name, which search_by matched exactly in the deprecated + # register_entity and flipside_entity options, into a search block. + def self.column_search(search_by) + return search_by unless search_by.is_a?(Symbol) + + ->(query) { where(search_by => query) } + end def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil) @class_name = class_name @@ -11,8 +20,12 @@ def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil) @identified_by = identified_by end + def options + {search_by:, display_as:, identified_by:} + end + def search(query) - Array(lookup_proc.call(query)).map do |entity| + Array(lookup(query)).map do |entity| SearchResult.new( entity, display(entity), @@ -22,39 +35,32 @@ def search(query) end def find(identifier) - class_name.constantize.find_by!("#{identified_by}": identifier) + klass.find_by!("#{identified_by}": identifier) end def display(entity) - display_proc.call(entity) + case display_as + when Proc then display_as.call(entity) + when Symbol then entity.public_send(display_as) + when String then display_as + else entity.public_send(identified_by) + end end private - def lookup_proc - @lookup_proc ||= - case search_by - when Proc - search_by - when Symbol - ->(query) { class_name.constantize.where("#{search_by}": query) } - else - ->(query) { class_name.constantize.where("#{identified_by}": query) } - end - end - - def display_proc - @display_proc ||= - case display_as - when Proc - display_as - when Symbol - ->(entity) { entity.public_send(display_as) } - when String - ->(entity) { display_as } - else - ->(entity) { entity.public_send(identified_by) } - end + # A block runs in the context of the class and a Symbol names a class + # method (e.g. a scope), so both can call where directly. + def lookup(query) + case search_by + when Proc then klass.instance_exec(query, &search_by) + when Symbol then klass.public_send(search_by, query) + else klass.where("#{identified_by}": query) + end + end + + def klass + class_name.constantize end end end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index f0b13a2..8da14c5 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -184,6 +184,36 @@ def display(user) end end + describe "registration" do + before { Flipside.flippables = ["User", "Admin"] } + + it "starts over when the class is reloaded" do + define_user do + flipside_display_as :name + flipside_identified_by :name + end + define_user { flipside_entity } + + expect(Flipside.display_entity(User.new(id: 5, name: "John Doe"))).to eq(5) + end + + it "copies the inherited settings to an STI subclass calling a macro" do + define_user do + flipside_search_by { |str| where(name: str) } + flipside_display_as :name + end + stub_const("Admin", Class.new(User)) + Admin.flipside_identified_by :name + Admin.create!(name: "John Doe") + + results = Flipside.search_entity(class_name: "Admin", query: "John Doe") + + expect(results.map { [_1.display_as, _1.identifier] }).to eq([["John Doe", "John Doe"]]) + expect(Flipside.search_entity(class_name: "User", query: "John Doe").map(&:identifier)) + .to eq([User.first.id]) + end + end + describe "deprecated flipside_entity options" do before { Flipside.flippables = ["User"] } diff --git a/spec/flipside/registered_entity_spec.rb b/spec/flipside/registered_entity_spec.rb index 615d3cd..7b85a78 100644 --- a/spec/flipside/registered_entity_spec.rb +++ b/spec/flipside/registered_entity_spec.rb @@ -27,10 +27,11 @@ module Flipside User.create(name: "bar", email: "bar@example.com") end - it "can search and show an entity by symbol" do + it "can search and show an entity by method name" do + User.scope(:by_email, ->(str) { where(email: str) }) registered_entity = RegisteredEntity.new( class_name: "User", - search_by: :email, + search_by: :by_email, display_as: :email, identified_by: :email ) @@ -41,6 +42,30 @@ module Flipside expect(result.first.display_as).to eq("bar@example.com") end + it "runs a search block in the context of the class" do + registered_entity = RegisteredEntity.new( + class_name: "User", + search_by: ->(str) { where(name: str) } + ) + + expect(registered_entity.search("foo").map(&:object)).to eq(User.where(name: "foo").to_a) + end + + it "searches by identified_by by default" do + registered_entity = RegisteredEntity.new(class_name: "User", identified_by: :email) + + expect(registered_entity.search("foo@example.com").map(&:display_as)).to eq(["foo@example.com"]) + end + + it "turns a column name into a search block for the deprecated options" do + registered_entity = RegisteredEntity.new( + class_name: "User", + search_by: RegisteredEntity.column_search(:email) + ) + + expect(registered_entity.search("bar@example.com").map(&:object).map(&:name)).to eq(["bar"]) + end + it "can search an entity by proc" do registered_entity = RegisteredEntity.new( class_name: "User", @@ -60,7 +85,6 @@ module Flipside registered_entity = RegisteredEntity.new( class_name: "User", - search_by: :email, identified_by: :id ) From 198413f43e88c58acaa61b4688d6439fa6035d0d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson Date: Thu, 8 Oct 2026 11:06:08 +0200 Subject: [PATCH 3/4] Keep matching a column when search_by is a Symbol flipside_search_by :name now matches the name column exactly, as search_by: :name always has, so moving to the new macro changes nothing. This drops the helper that translated the old meaning. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 13 ++++++------ app/models/flipside/flippable.rb | 27 ++++++++++++------------ lib/flipside/config/entities.rb | 1 - lib/flipside/config/registered_entity.rb | 14 +++--------- spec/flipside/flippable_spec.rb | 9 +++----- spec/flipside/registered_entity_spec.rb | 14 ++---------- 6 files changed, 27 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index da1e5df..99801d8 100644 --- a/README.md +++ b/README.md @@ -179,12 +179,12 @@ 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 the entity macros (`flipside_entity`, `flipside_search_by`, `flipside_display_as`) and/or `flipside_role`: +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`: ```ruby class User < ApplicationRecord include Flipside::Flippable - flipside_search_by { |str| where(name: str) } + flipside_search_by :name flipside_display_as :name flipside_role :admin? flipside_role :awesome?, display_as: "Awesome users" @@ -233,10 +233,9 @@ flipside_search_by do |str| end ``` -Instead of a block, it may be given the name of a class method (e.g. a scope), which is called with the search string: +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)`: ```ruby -scope :lookup, ->(str) { where(email: str) } -flipside_search_by :lookup +flipside_search_by :name ``` Without `flipside_search_by`, records are found by an exact match on `identified_by`. @@ -266,7 +265,7 @@ Without `flipside_display_as`, entities are displayed by their `identified_by` v 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. -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. Note that a `Symbol` given to `search_by:` matched that attribute exactly, whereas a method name given to `flipside_search_by` names a class method, so `search_by: :name` becomes `flipside_search_by { |str| where(name: str) }`. +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. ### Roles @@ -312,7 +311,7 @@ Flipside.flippables = %w[User] class User < ApplicationRecord include Flipside::Flippable - flipside_search_by { |str| where(name: str) } + flipside_search_by :name flipside_display_as :name flipside_role :admin? end diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb index 5e2d430..879a3cb 100644 --- a/app/models/flipside/flippable.rb +++ b/app/models/flipside/flippable.rb @@ -29,18 +29,19 @@ def flipside_entity(search_by: nil, display_as: nil, identified_by: nil) end # Sets how entities are found from a search in the UI, either with a block - # run in the context of the class or the name of a class method (e.g. a - # scope). Either is given the search string and returns the matching - # records. Registers the class as an entity, as flipside_entity does. - def flipside_search_by(method_name = nil, &block) - flipside_registration.search_by = method_or_block(method_name, block) + # or the name of a column. The block is run in the context of the class, + # given the search string, and returns the matching records. A column + # matches the search string exactly. Registers the class as an entity, as + # flipside_entity does. + def flipside_search_by(column = nil, &block) + flipside_registration.search_by = name_or_block(column, block) end # Sets how entities are displayed in the UI, either with a block given the # record or the name of an instance method. Either returns a string. # Registers the class as an entity, as flipside_entity does. def flipside_display_as(method_name = nil, &block) - flipside_registration.display_as = method_or_block(method_name, block) + flipside_registration.display_as = name_or_block(method_name, block) end # Sets the column that identifies an entity, :id by default. Registers the @@ -81,11 +82,11 @@ def flipside_registration ) end - def method_or_block(method_name, block) - return method_name.to_sym if method_name && !block - return block if block && !method_name + def name_or_block(name, block) + return name.to_sym if name && !block + return block if block && !name - raise ArgumentError, "pass either a method name or a block" + raise ArgumentError, "pass either a name or a block" end def deprecated_entity_options(search_by:, display_as:, identified_by:) @@ -95,13 +96,11 @@ def deprecated_entity_options(search_by:, display_as:, identified_by:) "The search_by, display_as and identified_by options of flipside_entity are " \ "deprecated and will be removed in Flipside #{Flipside.deprecator.deprecation_horizon}. " \ "Use flipside_search_by, flipside_display_as and flipside_identified_by in " \ - "#{name} instead. Note that a method name given to flipside_search_by names a " \ - "class method or scope, so search_by: :name becomes " \ - "flipside_search_by { |query| where(name: query) }." + "#{name} instead." ) registration = flipside_registration - registration.search_by = RegisteredEntity.column_search(search_by) if search_by + registration.search_by = search_by if search_by registration.display_as = display_as if display_as registration.identified_by = identified_by if identified_by end diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index f3f7d0a..0f9e977 100644 --- a/lib/flipside/config/entities.rb +++ b/lib/flipside/config/entities.rb @@ -9,7 +9,6 @@ def register_entity(class_name:, search_by:, display_as:, identified_by: :id) "#{deprecator.deprecation_horizon}. Include Flipside::Flippable in " \ "#{class_name}, call flipside_entity there and list it in Flipside.flippables." ) - search_by = RegisteredEntity.column_search(search_by) add_registered_entity(class_name:, search_by:, display_as:, identified_by:) end diff --git a/lib/flipside/config/registered_entity.rb b/lib/flipside/config/registered_entity.rb index b2958c2..46766f4 100644 --- a/lib/flipside/config/registered_entity.rb +++ b/lib/flipside/config/registered_entity.rb @@ -5,14 +5,6 @@ class RegisteredEntity attr_reader :class_name attr_accessor :search_by, :display_as, :identified_by - # Turns a column name, which search_by matched exactly in the deprecated - # register_entity and flipside_entity options, into a search block. - def self.column_search(search_by) - return search_by unless search_by.is_a?(Symbol) - - ->(query) { where(search_by => query) } - end - def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil) @class_name = class_name @search_by = search_by @@ -49,12 +41,12 @@ def display(entity) private - # A block runs in the context of the class and a Symbol names a class - # method (e.g. a scope), so both can call where directly. + # A block runs in the context of the class, so it can call where directly. + # A Symbol names a column to match exactly. def lookup(query) case search_by when Proc then klass.instance_exec(query, &search_by) - when Symbol then klass.public_send(search_by, query) + when Symbol then klass.where("#{search_by}": query) else klass.where("#{identified_by}": query) end end diff --git a/spec/flipside/flippable_spec.rb b/spec/flipside/flippable_spec.rb index 8da14c5..6c872b0 100644 --- a/spec/flipside/flippable_spec.rb +++ b/spec/flipside/flippable_spec.rb @@ -87,11 +87,8 @@ def search(query) expect(search("Ja")).to eq([jane]) end - it "searches with a class method" do - User.class_eval do - scope :named, ->(str) { where(name: str) } - flipside_search_by :named - end + it "searches by a column" do + User.flipside_search_by :name expect(search("John Doe")).to eq([john]) end @@ -121,7 +118,7 @@ def search(query) it "requires either a method name or a block" do expect { User.flipside_search_by }.to raise_error(ArgumentError) - expect { User.flipside_search_by(:named) { nil } }.to raise_error(ArgumentError) + expect { User.flipside_search_by(:name) { nil } }.to raise_error(ArgumentError) end end diff --git a/spec/flipside/registered_entity_spec.rb b/spec/flipside/registered_entity_spec.rb index 7b85a78..73d851f 100644 --- a/spec/flipside/registered_entity_spec.rb +++ b/spec/flipside/registered_entity_spec.rb @@ -27,11 +27,10 @@ module Flipside User.create(name: "bar", email: "bar@example.com") end - it "can search and show an entity by method name" do - User.scope(:by_email, ->(str) { where(email: str) }) + it "can search and show an entity by symbol" do registered_entity = RegisteredEntity.new( class_name: "User", - search_by: :by_email, + search_by: :email, display_as: :email, identified_by: :email ) @@ -57,15 +56,6 @@ module Flipside expect(registered_entity.search("foo@example.com").map(&:display_as)).to eq(["foo@example.com"]) end - it "turns a column name into a search block for the deprecated options" do - registered_entity = RegisteredEntity.new( - class_name: "User", - search_by: RegisteredEntity.column_search(:email) - ) - - expect(registered_entity.search("bar@example.com").map(&:object).map(&:name)).to eq(["bar"]) - end - it "can search an entity by proc" do registered_entity = RegisteredEntity.new( class_name: "User", From 21391d3d6e9188048bc3ee74709f39d31f9af93d Mon Sep 17 00:00:00 2001 From: Sammy Henningsson <125872158+sammyapoex@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:42:07 +0200 Subject: [PATCH 4/4] Update README to clarify flipside_identified_by usage Clarify the purpose of flipside_identified_by. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 99801d8..16e83d3 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ class User < ApplicationRecord end ``` -`flipside_identified_by` sets the column used as primary key for the corresponding table. This defaults to `:id` and typically does not need to be changed. +`flipside_identified_by` sets the column Flipside uses to identify records. This defaults to `:id` and typically does not need to be changed. Currently composite keys are not supported. ```ruby flipside_identified_by :uuid