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 all 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
275 changes: 275 additions & 0 deletions lib/avo/concerns/has_field_discovery.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
# 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

DEFAULT_COLUMN_NAMES_MAPPING = {
id: {field: "id"},
description: {field: "textarea"},
gravatar: {field: "gravatar"},
email: {field: "text"},
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
.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
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 |options|
{
field: :select,
options:
}
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
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
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
ObiWanKeoni marked this conversation as resolved.
Show resolved Hide resolved
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
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 @@ -125,6 +127,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 @@ -101,6 +101,10 @@
# type: :countless
# }
# end

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

if defined?(Avo::DynamicFilters)
Expand Down
Loading
Loading