-
-
Notifications
You must be signed in to change notification settings - Fork 269
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * 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 <[email protected]>
- Loading branch information
1 parent
c717dce
commit 8bf2b5e
Showing
9 changed files
with
594 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
4 changes: 4 additions & 0 deletions
4
spec/dummy/app/controllers/avo/field_discovery_users_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.