diff --git a/lib/avo/concerns/has_field_discovery.rb b/lib/avo/concerns/has_field_discovery.rb new file mode 100644 index 000000000..46ffc863b --- /dev/null +++ b/lib/avo/concerns/has_field_discovery.rb @@ -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 diff --git a/lib/avo/configuration.rb b/lib/avo/configuration.rb index ed5a14444..42a3ba6f0 100644 --- a/lib/avo/configuration.rb +++ b/lib/avo/configuration.rb @@ -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" @@ -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 diff --git a/lib/avo/resources/base.rb b/lib/avo/resources/base.rb index b455e25f9..e24a9e914 100644 --- a/lib/avo/resources/base.rb +++ b/lib/avo/resources/base.rb @@ -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 diff --git a/lib/avo/resources/items/sidebar.rb b/lib/avo/resources/items/sidebar.rb index 0e894cfd8..e1927e3d2 100644 --- a/lib/avo/resources/items/sidebar.rb +++ b/lib/avo/resources/items/sidebar.rb @@ -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 @@ -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 diff --git a/spec/dummy/app/avo/resources/compact_user.rb b/spec/dummy/app/avo/resources/compact_user.rb index fcb7995ef..619abdda4 100644 --- a/spec/dummy/app/avo/resources/compact_user.rb +++ b/spec/dummy/app/avo/resources/compact_user.rb @@ -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 diff --git a/spec/dummy/app/avo/resources/field_discovery_user.rb b/spec/dummy/app/avo/resources/field_discovery_user.rb new file mode 100644 index 000000000..ec05c17da --- /dev/null +++ b/spec/dummy/app/avo/resources/field_discovery_user.rb @@ -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 here.' + field :password_confirmation, as: :password, name: "Password confirmation", required: false, revealable: true + + with_options only_on: :forms do + field :dev, as: :heading, label: '