Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
60 changes: 41 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`, `flipside_identified_by`) and/or `flipside_role`:
```ruby
class User < ApplicationRecord
include Flipside::Flippable

flipside_entity search_by: :name, display_as: :name
flipside_search_by :name
flipside_display_as :name
flipside_role :admin?
flipside_role :awesome?, display_as: "Awesome users"
end
Expand All @@ -204,48 +205,68 @@ 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 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
```

`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 column, e.g. `:name`. Then records with an exact match on that column are returned, i.e. `User.where(name: query)`:
```ruby
flipside_search_by :name
```

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(" ")
end
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.

### Roles

Features can be enabled for certain roles, by searching for roles (by method name).
Expand All @@ -271,7 +292,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
```

Expand All @@ -290,7 +311,8 @@ Flipside.flippables = %w[User]
class User < ApplicationRecord
include Flipside::Flippable

flipside_entity search_by: :name, display_as: :name
flipside_search_by :name
flipside_display_as :name
flipside_role :admin?
end
```
Expand Down
82 changes: 74 additions & 8 deletions app/models/flipside/flippable.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,18 +12,42 @@ module Flipside
module Flippable
extend ActiveSupport::Concern

included do
# 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
# 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)
flipside_registration
deprecated_entity_options(search_by:, display_as:, identified_by:)
end

has_many :flipside_entities,
class_name: "Flipside::Entity",
as: :flippable,
dependent: :delete_all
# Sets how entities are found from a search in the UI, either with a 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 = name_or_block(method_name, block)
end

Flipside.add_registered_entity(class_name: name, search_by:, display_as:, identified_by:)
# 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)
Expand All @@ -38,6 +63,47 @@ def register_flippable!

Flipside.register_flippable(name)
end

# 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!

has_many :flipside_entities,
class_name: "Flipside::Entity",
as: :flippable,
dependent: :delete_all

self._flipside_registration = Flipside.add_registered_entity(
class_name: name,
**(_flipside_registration&.options || {})
)
end

def name_or_block(name, block)
return name.to_sym if name && !block
return block if block && !name

raise ArgumentError, "pass either a name or a block"
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."
)

registration = flipside_registration
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
end
end
end
2 changes: 1 addition & 1 deletion lib/flipside/config/entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def register_entity(class_name:, search_by:, display_as:, identified_by: :id)
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:,
Expand Down
50 changes: 24 additions & 26 deletions lib/flipside/config/registered_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

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

def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil)
@class_name = class_name
Expand All @@ -11,8 +12,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),
Expand All @@ -22,39 +27,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
# 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.where("#{search_by}": query)
else klass.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
def klass
class_name.constantize
end
end
end
Loading
Loading