Skip to content

Commit

Permalink
feature: add automatic field detection in resources (#3516)
Browse files Browse the repository at this point in the history
* feature: add automatic field detection in resources

* Apply suggestions from code review

Co-authored-by: Paul Bob <[email protected]>

* Optimize model enum check

* Rubocop / Refactor for readability + solving problems with select inputs

* Fix up tags and rich texts a bit

* Rubocop

* Oops - use `standardrb` instead of `rubocop`

* Few more lint fixes

* Couple more

* Indentation

* PR suggestions

* Lint spec file

* Remove custom resource in favor of using temporary items

* Add after blocks for cleanup

* Lint

* Add back resource with discovered fields

* Fix status issue and remedy test setup

* Update to use Avo::Mappings

* Higher specificity for specs

* Attempt to wait for post to load

* More reliable specs

---------

Co-authored-by: Paul Bob <[email protected]>
  • Loading branch information
ObiWanKeoni and Paul-Bob authored Jan 31, 2025
1 parent c717dce commit 8bf2b5e
Show file tree
Hide file tree
Showing 9 changed files with 594 additions and 7 deletions.
243 changes: 243 additions & 0 deletions lib/avo/concerns/has_field_discovery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# frozen_string_literal: true

# TODO: Refactor this concern to be more readable and maintainable
# rubocop:disable Metrics/ModuleLength
module Avo
module Concerns
# This concern facilitates field discovery for models in Avo,
# mapping database columns and associations to Avo fields.
# It supports:
# - Automatic detection of fields based on column names, types, and associations.
# - Customization via `only`, `except`, and global configuration overrides.
# - Handling of special associations like rich text, attachments, and tags.
module HasFieldDiscovery
extend ActiveSupport::Concern

COLUMN_NAMES_TO_IGNORE = %i[
encrypted_password reset_password_token reset_password_sent_at remember_created_at password_digest
].freeze

class_methods do
def column_names_mapping
@column_names_mapping ||= Avo::Mappings::NAMES_MAPPING.dup
.merge(Avo.configuration.column_names_mapping || {})
end

def column_types_mapping
@column_types_mapping ||= Avo::Mappings::FIELDS_MAPPING.dup
.merge(Avo.configuration.column_types_mapping || {})
end
end

# Returns database columns for the model, excluding ignored columns
def model_db_columns
@model_db_columns ||= safe_model_class.columns_hash.symbolize_keys.except(*COLUMN_NAMES_TO_IGNORE)
end

# Discovers and configures database columns as fields
def discover_columns(only: nil, except: nil, **field_options)
setup_discovery_options(only, except, field_options)
return unless safe_model_class.respond_to?(:columns_hash)

discoverable_columns.each do |column_name, column|
process_column(column_name, column)
end

discover_tags
discover_rich_texts
end

# Discovers and configures associations as fields
def discover_associations(only: nil, except: nil, **field_options)
setup_discovery_options(only, except, field_options)
return unless safe_model_class.respond_to?(:reflections)

discover_attachments
discover_basic_associations
end

private

def setup_discovery_options(only, except, field_options)
@only = only
@except = except
@field_options = field_options
end

def discoverable_columns
model_db_columns.reject do |column_name, _|
skip_column?(column_name)
end
end

def skip_column?(column_name)
!column_in_scope?(column_name) ||
reflections.key?(column_name) ||
rich_text_column?(column_name)
end

def rich_text_column?(column_name)
rich_texts.key?(:"rich_text_#{column_name}")
end

def process_column(column_name, column)
field_config = determine_field_config(column_name, column)
return unless field_config

create_field(column_name, field_config)
end

def create_field(column_name, field_config)
field_options = {as: field_config.dup.delete(:field).to_sym}.merge(field_config)
field(column_name, **field_options.symbolize_keys, **@field_options.symbolize_keys)
end

def create_attachment_field(association_name, reflection)
field_name = association_name&.to_s&.delete_suffix("_attachment")&.to_sym || association_name
field_type = determine_attachment_field_type(reflection)
field(field_name, as: field_type, **@field_options)
end

def determine_attachment_field_type(reflection)
(
reflection.is_a?(ActiveRecord::Reflection::HasOneReflection) ||
reflection.is_a?(ActiveStorage::Reflection::HasOneAttachedReflection)
) ? :file : :files
end

def create_association_field(association_name, reflection)
options = base_association_options(reflection)
options.merge!(polymorphic_options(reflection)) if reflection.options[:polymorphic]

field(association_name, **options, **@field_options)
end

def base_association_options(reflection)
{
as: reflection.macro,
searchable: true,
sortable: true
}
end

# Fetches the model class, falling back to the items_holder parent record in certain instances
# (e.g. in the context of the sidebar)
def safe_model_class
respond_to?(:model_class) ? model_class : @items_holder.parent.model_class
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
nil
end

def model_enums
@model_enums ||= if safe_model_class.respond_to?(:defined_enums)
safe_model_class.defined_enums.transform_values do |enum|
{
field: :select,
enum:
}
end
else
{}
end.with_indifferent_access
end

# Determines if a column is included in the discovery scope.
# A column is in scope if it's included in `only` and not in `except`.
def column_in_scope?(column_name)
(!@only || @only.include?(column_name)) && (!@except || !@except.include?(column_name))
end

def determine_field_config(attribute, column)
model_enums[attribute.to_s] ||
self.class.column_names_mapping[attribute] ||
self.class.column_types_mapping[column.type]
end

def discover_by_type(associations, as_type)
associations.each_key do |association_name|
next unless column_in_scope?(association_name)

field association_name, as: as_type, **@field_options.merge(name: yield(association_name))
end
end

def discover_rich_texts
rich_texts.each_key do |association_name|
next unless column_in_scope?(association_name)

field_name = association_name&.to_s&.delete_prefix("rich_text_")&.to_sym || association_name
field field_name, as: :trix, **@field_options
end
end

def discover_tags
tags.each_key do |association_name|
next unless column_in_scope?(association_name)

field(
tag_field_name(association_name), as: :tags,
acts_as_taggable_on: tag_field_name(association_name),
**@field_options
)
end
end

def tag_field_name(association_name)
association_name&.to_s&.delete_suffix("_taggings")&.pluralize&.to_sym || association_name
end

def discover_attachments
attachment_associations.each do |association_name, reflection|
next unless column_in_scope?(association_name)

create_attachment_field(association_name, reflection)
end
end

def discover_basic_associations
associations.each do |association_name, reflection|
next unless column_in_scope?(association_name)

create_association_field(association_name, reflection)
end
end

def polymorphic_options(reflection)
{polymorphic_as: reflection.name, types: detect_polymorphic_types(reflection)}
end

def detect_polymorphic_types(reflection)
ApplicationRecord.descendants.select { |klass| klass.reflections[reflection.plural_name] }
end

def reflections
@reflections ||= safe_model_class.reflections.symbolize_keys.reject do |name, _|
ignore_reflection?(name.to_s)
end
end

def attachment_associations
@attachment_associations ||= reflections.select { |_, r| r.options[:class_name] == "ActiveStorage::Attachment" }
end

def rich_texts
@rich_texts ||= reflections.select { |_, r| r.options[:class_name] == "ActionText::RichText" }
end

def tags
@tags ||= reflections.select { |_, r| r.options[:as] == :taggable }
end

def associations
@associations ||= reflections.reject do |key|
attachment_associations.key?(key) || tags.key?(key) || rich_texts.key?(key)
end
end

def ignore_reflection?(name)
%w[blob blobs tags].include?(name.split("_").pop) || name.to_sym == :taggings
end
end
end
end
# rubocop:enable Metrics/ModuleLength
4 changes: 4 additions & 0 deletions lib/avo/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class Configuration
attr_accessor :search_results_count
attr_accessor :first_sorting_option
attr_accessor :associations_lookup_list_limit
attr_accessor :column_names_mapping
attr_accessor :column_types_mapping

def initialize
@root_path = "/avo"
Expand Down Expand Up @@ -124,6 +126,8 @@ def initialize
@first_sorting_option = :desc # :desc or :asc
@associations_lookup_list_limit = 1000
@exclude_from_status = []
@column_names_mapping = {}
@column_types_mapping = {}
@resource_row_controls_config = {}
end

Expand Down
1 change: 1 addition & 0 deletions lib/avo/resources/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Base
extend ActiveSupport::DescendantsTracker

include ActionView::Helpers::UrlHelper
include Avo::Concerns::HasFieldDiscovery
include Avo::Concerns::HasItems
include Avo::Concerns::CanReplaceItems
include Avo::Concerns::HasControls
Expand Down
2 changes: 2 additions & 0 deletions lib/avo/resources/items/sidebar.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class Avo::Resources::Items::Sidebar
prepend Avo::Concerns::IsResourceItem

include Avo::Concerns::HasFieldDiscovery
include Avo::Concerns::HasItems
include Avo::Concerns::HasItemType
include Avo::Concerns::IsVisible
Expand All @@ -26,6 +27,7 @@ def panel_wrapper?

class Builder
include Avo::Concerns::BorrowItemsHolder
include Avo::Concerns::HasFieldDiscovery

delegate :field, to: :items_holder
delegate :tool, to: :items_holder
Expand Down
10 changes: 3 additions & 7 deletions spec/dummy/app/avo/resources/compact_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ class Avo::Resources::CompactUser < Avo::BaseResource

def fields
field :personal_information, as: :heading

field :first_name, as: :text
field :last_name, as: :text
field :birthday, as: :date
discover_columns only: [:first_name, :last_name, :birthday]

field :heading, as: :heading, label: "Contact"
discover_columns only: [:email]

field :email, as: :text

field :posts, as: :has_many
discover_associations only: [:posts]
end
end
35 changes: 35 additions & 0 deletions spec/dummy/app/avo/resources/field_discovery_user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Avo::Resources::FieldDiscoveryUser < Avo::BaseResource
self.model_class = ::User
self.description = "This is a resource with discovered fields. It will show fields and associations as defined in the model."
self.find_record_method = -> {
query.friendly.find id
}

def fields
main_panel do
discover_columns except: %i[email active is_admin? birthday is_writer outside_link custom_css]
discover_associations only: %i[cv_attachment]

sidebar do
with_options only_on: :show do
discover_columns only: %i[email], as: :gravatar, link_to_record: true, as_avatar: :circle
field :heading, as: :heading, label: ""
discover_columns only: %i[active], name: "Is active"
end

discover_columns only: %i[birthday]

field :password, as: :password, name: "User Password", required: false, only_on: :forms, help: 'You may verify the password strength <a href="http://www.passwordmeter.com/" target="_blank">here</a>.'
field :password_confirmation, as: :password, name: "Password confirmation", required: false, revealable: true

with_options only_on: :forms do
field :dev, as: :heading, label: '<div class="underline uppercase font-bold">DEV</div>', as_html: true
discover_columns only: %i[custom_css]
end
end
end

discover_associations only: %i[posts]
discover_associations except: %i[posts post cv_attachment]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/3.0/controllers.html
class Avo::FieldDiscoveryUsersController < Avo::ResourcesController
end
4 changes: 4 additions & 0 deletions spec/dummy/config/initializers/avo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@
# type: :countless
# }
# end

config.column_names_mapping = {
custom_css: {field: "code"}
}
end

if defined?(Avo::DynamicFilters)
Expand Down
Loading

0 comments on commit 8bf2b5e

Please sign in to comment.