From 8bf2b5e9ddc3cca65b1bb41f37397e86e4bbb780 Mon Sep 17 00:00:00 2001 From: Keoni Garner Date: Fri, 31 Jan 2025 07:35:06 -0800 Subject: [PATCH] feature: add automatic field detection in resources (#3516) * feature: add automatic field detection in resources * Apply suggestions from code review Co-authored-by: Paul Bob <69730720+Paul-Bob@users.noreply.github.com> * 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 <69730720+Paul-Bob@users.noreply.github.com> --- lib/avo/concerns/has_field_discovery.rb | 243 ++++++++++++++ lib/avo/configuration.rb | 4 + lib/avo/resources/base.rb | 1 + lib/avo/resources/items/sidebar.rb | 2 + spec/dummy/app/avo/resources/compact_user.rb | 10 +- .../app/avo/resources/field_discovery_user.rb | 35 ++ .../avo/field_discovery_users_controller.rb | 4 + spec/dummy/config/initializers/avo.rb | 4 + spec/system/avo/has_field_discovery_spec.rb | 298 ++++++++++++++++++ 9 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 lib/avo/concerns/has_field_discovery.rb create mode 100644 spec/dummy/app/avo/resources/field_discovery_user.rb create mode 100644 spec/dummy/app/controllers/avo/field_discovery_users_controller.rb create mode 100644 spec/system/avo/has_field_discovery_spec.rb diff --git a/lib/avo/concerns/has_field_discovery.rb b/lib/avo/concerns/has_field_discovery.rb new file mode 100644 index 0000000000..46ffc863b4 --- /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 ed5a144449..42a3ba6f0a 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 b455e25f93..e24a9e914c 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 0e894cfd88..e1927e3d2a 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 fcb7995efe..619abdda4a 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 0000000000..ec05c17daf --- /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: '
DEV
', 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 diff --git a/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb b/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb new file mode 100644 index 0000000000..4ef6e31b11 --- /dev/null +++ b/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb @@ -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 diff --git a/spec/dummy/config/initializers/avo.rb b/spec/dummy/config/initializers/avo.rb index 715a034407..063fd18fba 100644 --- a/spec/dummy/config/initializers/avo.rb +++ b/spec/dummy/config/initializers/avo.rb @@ -100,6 +100,10 @@ # type: :countless # } # end + + config.column_names_mapping = { + custom_css: {field: "code"} + } end if defined?(Avo::DynamicFilters) diff --git a/spec/system/avo/has_field_discovery_spec.rb b/spec/system/avo/has_field_discovery_spec.rb new file mode 100644 index 0000000000..e5ab6bca0a --- /dev/null +++ b/spec/system/avo/has_field_discovery_spec.rb @@ -0,0 +1,298 @@ +require "rails_helper" + +RSpec.describe Avo::Concerns::HasFieldDiscovery, type: :system do + let!(:user) { create :user, first_name: "John", last_name: "Doe", birthday: "1990-01-01", email: "john.doe@example.com" } + let!(:post) { create :post, user: user, name: "Sample Post" } + + before do + Avo::Resources::User.with_temporary_items do + 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: '
DEV
', 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 + + after do + Avo::Resources::User.restore_items_from_backup + end + + describe "Show Page" do + let(:url) { "/admin/resources/users/#{user.slug}" } + + before { visit url } + + it "displays discovered columns correctly" do + wait_for_loaded + + # Verify discovered columns + expect(page).to have_text "FIRST NAME" + expect(page).to have_text "John" + expect(page).to have_text "LAST NAME" + expect(page).to have_text "Doe" + expect(page).to have_text "BIRTHDAY" + expect(page).to have_text "1990-01-01" + + # Verify excluded fields are not displayed + expect(page).not_to have_text "IS ADMIN?" + expect(page).not_to have_text "CUSTOM CSS" + end + + it "displays the email as a gravatar field with a link to the record" do + within(".resource-sidebar-component") do + expect(page).to have_css("img") # Check for avatar + end + end + + it "displays discovered associations correctly" do + wait_for_loaded + + expect(page).to have_selector("#has_many_field_show_posts") + expect(page).to have_selector("#has_many_field_show_posts") + expect(page).to have_selector("#has_many_field_show_people") + expect(page).to have_selector("#has_many_field_show_spouses") + expect(page).to have_selector("#has_many_field_show_comments") + expect(page).to have_selector("#has_and_belongs_to_many_field_show_projects") + expect(page).to have_selector("#has_many_field_show_team_memberships") + expect(page).to have_selector("#has_many_field_show_teams") + + # Verify `cv_attachment` association is present + expect(page).to have_text "CV" + end + + it "renders each field exactly once" do + wait_for_loaded + + within(".main-content-area") do + within("[data-panel-id='main']") do + # Basic fields + ## Main Panel + expect(page).to have_text("FIRST NAME", count: 1) + expect(page).to have_text("LAST NAME", count: 1) + expect(page).to have_text("ROLES", count: 1) + expect(page).to have_text("TEAM ID", count: 1) + expect(page).to have_text("CREATED AT", count: 1) + expect(page).to have_text("UPDATED AT", count: 1) + expect(page).to have_text("SLUG", count: 1) + + # Sidebar + expect(page).to have_text("AVATAR", count: 1) + expect(page).to have_text("IS ACTIVE", count: 1) + expect(page).to have_text("BIRTHDAY", count: 1) + + # Single file uploads + expect(page).to have_text("CV", count: 1) + expect(page).not_to have_text("CV ATTACHMENT") + end + + # Associations + expect(page).to have_selector("#has_many_field_show_posts", count: 1) + end + end + end + + describe "Index Page" do + let(:url) { "/admin/resources/users" } + + before { visit url } + + it "lists discovered fields in the index view" do + wait_for_loaded + + within("table") do + expect(page).to have_text "John" + expect(page).to have_text "Doe" + expect(page).to have_text user.slug + end + end + end + + describe "Form Page" do + let(:url) { "/admin/resources/users/#{user.id}/edit" } + + before { visit url } + + it "displays form-specific fields" do + wait_for_loaded + + # Verify form-only fields + expect(page).to have_field "User Password" + expect(page).to have_field "Password confirmation" + + # Verify custom CSS field is displayed + expect(page).to have_text "CUSTOM CSS" + + # Verify password fields allow input + fill_in "User Password", with: "new_password" + fill_in "Password confirmation", with: "new_password" + end + + it "renders each input field exactly once" do + wait_for_loaded + + # Form fields + expect(page).to have_text("FIRST NAME", count: 1) + expect(page).to have_text("LAST NAME", count: 1) + expect(page).to have_text("ROLES", count: 1) + expect(page).to have_text("TEAM ID", count: 1) + expect(page).to have_text("CREATED AT", count: 1) + expect(page).to have_text("UPDATED AT", count: 1) + expect(page).to have_text("SLUG", count: 1) + + # File upload fields + expect(page).to have_text("CV", count: 1) + + # Password fields + expect(page).to have_text("USER PASSWORD", count: 1) + expect(page).to have_text("PASSWORD CONFIRMATION", count: 1) + end + end + + describe "Has One Attachment" do + let(:url) { "/admin/resources/users/#{user.id}/edit" } + + before { visit url } + + it "displays single file upload correctly for has_one_attached" do + wait_for_loaded + + within('[data-field-id="cv"]') do + # Verify it shows "Choose File" instead of "Choose Files" + expect(page).to have_css('input[type="file"]:not([multiple])') + end + end + end + + describe "Trix Editor" do + let(:event) { create :event } + let(:url) { "/admin/resources/events/#{event.id}/edit" } + + after do + Avo::Resources::Event.restore_items_from_backup + end + + before do + Avo::Resources::Event.with_temporary_items do + discover_columns + end + visit url + end + + it "renders Trix editor only once" do + wait_for_loaded + + # Verify only one Trix editor instance is present + expect(page).to have_css("trix-editor", count: 1) + end + end + + describe "Tags" do + let(:post) { create :post } + let(:url) { "/admin/resources/posts/#{post.id}" } + + after do + Avo::Resources::Post.restore_items_from_backup + end + + before do + Avo::Resources::Post.with_temporary_items do + discover_columns + end + visit url + end + + it "renders tags correctly" do + wait_for_loaded + + # Verify only one Trix editor instance is present + expect(page).to have_text("TAGS", count: 1) + expect(page).to have_css('[data-target="tag-component"]') + end + end + + describe "Enum Fields" do + let(:post) { create :post } + let(:url) { "/admin/resources/posts/#{post.id}/edit" } + + after do + Avo::Resources::Post.restore_items_from_backup + end + + before do + Avo::Resources::Post.with_temporary_items do + discover_columns + end + visit url + end + + it "displays enum fields as select boxes" do + wait_for_loaded + + within('[data-field-id="status"]') do + expect(page).to have_css("select") + expect(page).to have_select(options: ["draft", "published", "archived"]) + expect(page).to have_select(selected: post.status) + end + end + end + + describe "Polymorphic Associations" do + let(:post) { create :post } + let(:comment) { create :comment, commentable: post } + + after do + Avo::Resources::Comment.restore_items_from_backup + end + + before do + Avo::Resources::Comment.with_temporary_items do + discover_associations + end + visit "/admin/resources/comments/#{comment.id}" + end + + it "displays polymorphic association correctly" do + wait_for_loaded + + within("[data-panel-id='main']") do + expect(page).to have_text("COMMENTABLE") + expect(page).to have_link(post.name, href: /\/admin\/resources\/posts\//) + end + end + end + + describe "Ignored Fields" do + before { visit "/admin/resources/users/#{user.slug}" } + + it "does not display sensitive fields" do + wait_for_loaded + + within("[data-panel-id='main']") do + expect(page).not_to have_text("ENCRYPTED_PASSWORD") + expect(page).not_to have_text("RESET_PASSWORD_TOKEN") + expect(page).not_to have_text("REMEMBER_CREATED_AT") + end + end + end +end