Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: add automatic field detection in resources #3516

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions lib/avo/concerns/has_field_discovery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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

DEFAULT_COLUMN_NAMES_MAPPING = {
id: { field: "id" },

Check failure on line 12 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:12:14: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. id: { field: "id" }, ^

Check failure on line 12 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:12:26: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. id: { field: "id" }, ^
description: { field: "textarea" },

Check failure on line 13 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:13:23: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. description: { field: "textarea" }, ^

Check failure on line 13 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:13:41: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. description: { field: "textarea" }, ^
gravatar: { field: "gravatar" },

Check failure on line 14 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:14:20: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. gravatar: { field: "gravatar" }, ^

Check failure on line 14 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:14:38: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. gravatar: { field: "gravatar" }, ^
email: { field: "text" },

Check failure on line 15 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:15:17: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. email: { field: "text" }, ^

Check failure on line 15 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:15:31: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside } detected. email: { field: "text" }, ^
password: { field: "password" },

Check failure on line 16 in lib/avo/concerns/has_field_discovery.rb

View workflow job for this annotation

GitHub Actions / lint / runner / standardrb

[rubocop] reported by reviewdog 🐶 [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. Raw Output: lib/avo/concerns/has_field_discovery.rb:16:20: C: [Corrected] Layout/SpaceInsideHashLiteralBraces: Space inside { detected. password: { field: "password" }, ^
password_confirmation: { field: "password" },
created_at: { field: "date_time" },
updated_at: { field: "date_time" },
stage: { field: "select" },
budget: { field: "currency" },
money: { field: "currency" },
country: { field: "country" },
}.freeze

DEFAULT_COLUMN_TYPES_MAPPING = {
primary_key: { field: "id" },
string: { field: "text" },
text: { field: "textarea" },
integer: { field: "number" },
float: { field: "number" },
decimal: { field: "number" },
datetime: { field: "date_time" },
timestamp: { field: "date_time" },
time: { field: "date_time" },
date: { field: "date" },
binary: { field: "number" },
boolean: { field: "boolean" },
references: { field: "belongs_to" },
json: { field: "code" },
}.freeze

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 ||= DEFAULT_COLUMN_NAMES_MAPPING.dup
.except(*COLUMN_NAMES_TO_IGNORE)
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
.merge(Avo.configuration.column_names_mapping || {})
end

def column_types_mapping
@column_types_mapping ||= DEFAULT_COLUMN_TYPES_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)
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
@only, @except, @field_options = only, except, field_options
return unless safe_model_class.respond_to?(:columns_hash)

model_db_columns.each do |column_name, column|
next unless column_in_scope?(column_name)
next if reflections.key?(column_name) || rich_texts.key?("rich_text_#{column_name}")

field_config = determine_field_config(column_name, column)
next unless field_config

field_options = build_field_options(field_config, column)
field column_name, **field_options, **@field_options
end
end

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

discover_by_type(tags, :tags) { |name| name.split("_").pop.join("_").pluralize }
discover_by_type(rich_texts, :trix) { |name| name.delete_prefix("rich_text_") }
discover_attachments
discover_basic_associations
end

private

# 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.record.class
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
nil
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)
if safe_model_class.respond_to?(:defined_enums) && safe_model_class.defined_enums[attribute.to_s]
return { field: "select", enum: "::#{safe_model_class.name}.#{attribute.to_s.pluralize}" }
end
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved

self.class.column_names_mapping[attribute] || self.class.column_types_mapping[column.type]
end

def build_field_options(field_config, column)
{ as: field_config[:field].to_sym, required: !column.null }.merge(field_config.except(:field))
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
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(yield(association_name))
end
end

def discover_attachments
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
attachment_associations.each do |association_name, reflection|
next unless column_in_scope?(association_name)

field_type = reflection.options[:as] == :has_one_attached ? :file : :files
field association_name, as: field_type, **@field_options
end
end

def discover_basic_associations
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
associations.each do |association_name, reflection|
next unless column_in_scope?(association_name)

options = { as: reflection.macro, searchable: true, sortable: true }
options.merge!(polymorphic_options(reflection)) if reflection.options[:polymorphic]

field association_name, **options, **@field_options
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 { |key| attachment_associations.key?(key) || tags.key?(key) || rich_texts.key?(key) }
end

def ignore_reflection?(name)
%w[blob blobs tags].include?(name.split("_").pop) || name.to_sym == :taggings
end
end
end
end
4 changes: 4 additions & 0 deletions lib/avo/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 @@ -123,6 +125,8 @@ def initialize
@first_sorting_option = :desc # :desc or :asc
@associations_lookup_list_limit = 1000
@exclude_from_status = []
@column_names_mapping = {}
@column_types_mapping = {}
end

# Authorization is enabled when:
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 @@ -102,6 +102,10 @@
# type: :countless
# }
# end

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

if defined?(Avo::DynamicFilters)
Expand Down
83 changes: 83 additions & 0 deletions spec/system/avo/has_field_discovery_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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" }

describe "Show Page" do
let(:url) { "/admin/resources/field_discovery_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

# Verify `posts` association
expect(page).to have_text "Posts"
expect(page).to have_text "Sample Post"
expect(page).to have_link "Sample Post", href: "/admin/resources/posts/#{post.slug}?via_record_id=#{user.slug}&via_resource_class=Avo%3A%3AResources%3A%3AFieldDiscoveryUser"

# Verify `cv_attachment` association is present
expect(page).to have_text "CV ATTACHMENT"
end
end

describe "Index Page" do
let(:url) { "/admin/resources/field_discovery_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/field_discovery_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
end
end
Loading