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

Merged
merged 24 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
617ead6
feature: add automatic field detection in resources
ObiWanKeoni Dec 11, 2024
b30d05a
Merge branch 'main' into feature/automatic-field-detection
ObiWanKeoni Dec 11, 2024
3e55c88
Apply suggestions from code review
ObiWanKeoni Jan 9, 2025
f6e8b23
Optimize model enum check
ObiWanKeoni Jan 9, 2025
ac66258
Rubocop / Refactor for readability + solving problems with select inputs
ObiWanKeoni Jan 10, 2025
b5f0c6f
Fix up tags and rich texts a bit
ObiWanKeoni Jan 10, 2025
657c2da
Rubocop
ObiWanKeoni Jan 10, 2025
c76f034
Oops - use `standardrb` instead of `rubocop`
ObiWanKeoni Jan 10, 2025
3faf2da
Few more lint fixes
ObiWanKeoni Jan 10, 2025
7bc8c90
Couple more
ObiWanKeoni Jan 10, 2025
9f1bc4c
Indentation
ObiWanKeoni Jan 10, 2025
cd5c5e5
Merge branch 'main' into feature/automatic-field-detection
Paul-Bob Jan 10, 2025
4b36f1f
PR suggestions
ObiWanKeoni Jan 10, 2025
329d0c8
Lint spec file
ObiWanKeoni Jan 10, 2025
d7dd291
Remove custom resource in favor of using temporary items
ObiWanKeoni Jan 10, 2025
2ffac80
Add after blocks for cleanup
ObiWanKeoni Jan 13, 2025
a6db237
Lint
ObiWanKeoni Jan 13, 2025
0892908
Add back resource with discovered fields
ObiWanKeoni Jan 14, 2025
82fab1d
Fix status issue and remedy test setup
ObiWanKeoni Jan 16, 2025
bff6416
Merge branch 'main' into feature/automatic-field-detection
ObiWanKeoni Jan 29, 2025
5d60d7e
Update to use Avo::Mappings
ObiWanKeoni Jan 29, 2025
8abf435
Higher specificity for specs
ObiWanKeoni Jan 30, 2025
bc04386
Attempt to wait for post to load
ObiWanKeoni Jan 30, 2025
490a2e2
More reliable specs
ObiWanKeoni Jan 30, 2025
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 || [email protected]?(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: "[email protected]" }
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