-
-
Notifications
You must be signed in to change notification settings - Fork 293
feature: add automatic field detection in resources #3516
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
Merged
Paul-Bob
merged 24 commits into
avo-hq:main
from
ObiWanKeoni:feature/automatic-field-detection
Jan 31, 2025
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
617ead6
feature: add automatic field detection in resources
ObiWanKeoni b30d05a
Merge branch 'main' into feature/automatic-field-detection
ObiWanKeoni 3e55c88
Apply suggestions from code review
ObiWanKeoni f6e8b23
Optimize model enum check
ObiWanKeoni ac66258
Rubocop / Refactor for readability + solving problems with select inputs
ObiWanKeoni b5f0c6f
Fix up tags and rich texts a bit
ObiWanKeoni 657c2da
Rubocop
ObiWanKeoni c76f034
Oops - use `standardrb` instead of `rubocop`
ObiWanKeoni 3faf2da
Few more lint fixes
ObiWanKeoni 7bc8c90
Couple more
ObiWanKeoni 9f1bc4c
Indentation
ObiWanKeoni cd5c5e5
Merge branch 'main' into feature/automatic-field-detection
Paul-Bob 4b36f1f
PR suggestions
ObiWanKeoni 329d0c8
Lint spec file
ObiWanKeoni d7dd291
Remove custom resource in favor of using temporary items
ObiWanKeoni 2ffac80
Add after blocks for cleanup
ObiWanKeoni a6db237
Lint
ObiWanKeoni 0892908
Add back resource with discovered fields
ObiWanKeoni 82fab1d
Fix status issue and remedy test setup
ObiWanKeoni bff6416
Merge branch 'main' into feature/automatic-field-detection
ObiWanKeoni 5d60d7e
Update to use Avo::Mappings
ObiWanKeoni 8abf435
Higher specificity for specs
ObiWanKeoni bc04386
Attempt to wait for post to load
ObiWanKeoni 490a2e2
More reliable specs
ObiWanKeoni File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 || [email protected]?(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 |
This file contains hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.