-
-
Notifications
You must be signed in to change notification settings - Fork 292
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
Changes from 1 commit
617ead6
b30d05a
3e55c88
f6e8b23
ac66258
b5f0c6f
657c2da
c76f034
3faf2da
7bc8c90
9f1bc4c
cd5c5e5
4b36f1f
329d0c8
d7dd291
2ffac80
a6db237
0892908
82fab1d
bff6416
5d60d7e
8abf435
bc04386
490a2e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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
|
||
description: { field: "textarea" }, | ||
Check failure on line 13 in lib/avo/concerns/has_field_discovery.rb
|
||
gravatar: { field: "gravatar" }, | ||
Check failure on line 14 in lib/avo/concerns/has_field_discovery.rb
|
||
email: { field: "text" }, | ||
Check failure on line 15 in lib/avo/concerns/has_field_discovery.rb
|
||
password: { field: "password" }, | ||
Check failure on line 16 in lib/avo/concerns/has_field_discovery.rb
|
||
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) | ||
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 |
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 |
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 |
Uh oh!
There was an error while loading. Please reload this page.