Skip to content

Commit

Permalink
feature: add automatic field detection in resources
Browse files Browse the repository at this point in the history
  • Loading branch information
ObiWanKeoni committed Dec 11, 2024
1 parent b476cb7 commit 617ead6
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 7 deletions.
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)
.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)
@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
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

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))
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
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
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

0 comments on commit 617ead6

Please sign in to comment.