diff --git a/CHANGELOG.md b/CHANGELOG.md index 2563e88..aebc383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index cc19c18..8e9bf70 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 @@ -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 @@ -123,7 +130,7 @@ Note: you probably want to wrap this inside a constraints block to provide some  -### Configuration +## Configuration Flipside can be configured by calling some class methods on `Flipside` (see below). @@ -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. ``` @@ -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.  -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. @@ -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).  -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. diff --git a/app/models/flipside/flippable.rb b/app/models/flipside/flippable.rb new file mode 100644 index 0000000..24e8139 --- /dev/null +++ b/app/models/flipside/flippable.rb @@ -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 diff --git a/lib/flipside.rb b/lib/flipside.rb index 1334f6d..1031118 100644 --- a/lib/flipside.rb +++ b/lib/flipside.rb @@ -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 @@ -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 @@ -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) diff --git a/lib/flipside/config/entities.rb b/lib/flipside/config/entities.rb index 194f8de..efabf2d 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:, @@ -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 diff --git a/lib/flipside/config/flippables.rb b/lib/flipside/config/flippables.rb new file mode 100644 index 0000000..94bc8c7 --- /dev/null +++ b/lib/flipside/config/flippables.rb @@ -0,0 +1,39 @@ +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 + + def register_flippable(class_name) # :nodoc: + registered_flippables << class_name.to_s + 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 + + 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 +end diff --git a/lib/flipside/config/roles.rb b/lib/flipside/config/roles.rb index 765e9b2..408b09d 100644 --- a/lib/flipside/config/roles.rb +++ b/lib/flipside/config/roles.rb @@ -4,8 +4,17 @@ 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( + 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:, method_name:, display_as: @@ -13,11 +22,13 @@ 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:) - registered_roles.fetch(class_name.to_s).filter_map do |registered_role| + 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 end 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/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 @@