Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## [Unreleased]
- 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

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

Expand Down
118 changes: 88 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. 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
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
Expand All @@ -123,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).

Expand Down Expand Up @@ -154,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.
```

Expand All @@ -170,34 +177,51 @@ Flipside.create_missing_features = true
Flipside.default_object = -> { Current.user }
```

#### Entities
### 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.
Expand All @@ -207,38 +231,72 @@ 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
### Roles

Features can be enabled for certain roles, by searching for roles (by method name).

![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
class User < ApplicationRecord
include Flipside::Flippable

flipside_role :admin?
flipside_role :awesome?, display_as: "Awesome users"
end
```

### Classes you cannot edit

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 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?)
Flipside.register_role(class_name: "User", method_name: :awesome?)

# 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?
end
```

In a Rails 7.1+ app the deprecation warnings follow the app's own deprecation settings (`Rails.application.deprecators[:flipside]`).

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests.
Expand Down
43 changes: 43 additions & 0 deletions app/models/flipside/flippable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

require "active_support/concern"
require "models/flipside/entity"

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. 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

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!

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

Flipside.add_registered_entity(class_name: name, search_by:, display_as:, identified_by:)
end

def flipside_role(method_name, display_as: nil)
register_flippable!

Flipside.add_registered_role(class_name: name, method_name:, display_as:)
end

private

def register_flippable!
raise Flipside::Error, "Flipside macros need a named class" if name.nil?

Flipside.register_flippable(name)
end
end
end
end
25 changes: 25 additions & 0 deletions lib/flipside.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
# frozen_string_literal: true

require "active_record"
require "active_support/deprecation"
require "flipside/version"
require "flipside/web"
require "flipside/config/settings"
require "flipside/config/entities"
require "flipside/config/roles"
require "flipside/config/flippables"
require "models/flipside/feature"
require "models/flipside/flippable"
require "flipside/railtie" if defined?(Rails::Railtie)

module Flipside
extend Config::Settings
extend Config::Entities
extend Config::Roles
extend Config::Flippables

class Error < StandardError; end

Expand All @@ -22,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
Expand Down Expand Up @@ -55,6 +64,22 @@ 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
.unscoped
.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)
Expand Down
37 changes: 34 additions & 3 deletions lib/flipside/config/entities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:,
Expand All @@ -13,25 +22,47 @@ 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

# Accepts either a flippable record or a Flipside::Entity join row. Given
# 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)
registered_entities
.fetch(entity.class.to_s)
.display(entity)
return display_flippable(entity) unless entity.is_a?(Flipside::Entity)

record = entity.flippable if entity.flippable_type.safe_constantize
label = "#{entity.flippable_type} ##{entity.flippable_id}"

if record.nil?
"#{label} (deleted)"
elsif registered_entities.key?(record.class.to_s)
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
Expand Down
Loading
Loading