-
-
Notifications
You must be signed in to change notification settings - Fork 270
Commit
- Loading branch information
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) | ||
@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 | ||
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 || !@except.include?(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 | ||
|
||
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)) | ||
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 | ||
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 | ||
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: "john.doe@example.com" } | ||
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 |