Skip to content

Commit 8bf2b5e

Browse files
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]>
1 parent c717dce commit 8bf2b5e

File tree

9 files changed

+594
-7
lines changed

9 files changed

+594
-7
lines changed
+243
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
# frozen_string_literal: true
2+
3+
# TODO: Refactor this concern to be more readable and maintainable
4+
# rubocop:disable Metrics/ModuleLength
5+
module Avo
6+
module Concerns
7+
# This concern facilitates field discovery for models in Avo,
8+
# mapping database columns and associations to Avo fields.
9+
# It supports:
10+
# - Automatic detection of fields based on column names, types, and associations.
11+
# - Customization via `only`, `except`, and global configuration overrides.
12+
# - Handling of special associations like rich text, attachments, and tags.
13+
module HasFieldDiscovery
14+
extend ActiveSupport::Concern
15+
16+
COLUMN_NAMES_TO_IGNORE = %i[
17+
encrypted_password reset_password_token reset_password_sent_at remember_created_at password_digest
18+
].freeze
19+
20+
class_methods do
21+
def column_names_mapping
22+
@column_names_mapping ||= Avo::Mappings::NAMES_MAPPING.dup
23+
.merge(Avo.configuration.column_names_mapping || {})
24+
end
25+
26+
def column_types_mapping
27+
@column_types_mapping ||= Avo::Mappings::FIELDS_MAPPING.dup
28+
.merge(Avo.configuration.column_types_mapping || {})
29+
end
30+
end
31+
32+
# Returns database columns for the model, excluding ignored columns
33+
def model_db_columns
34+
@model_db_columns ||= safe_model_class.columns_hash.symbolize_keys.except(*COLUMN_NAMES_TO_IGNORE)
35+
end
36+
37+
# Discovers and configures database columns as fields
38+
def discover_columns(only: nil, except: nil, **field_options)
39+
setup_discovery_options(only, except, field_options)
40+
return unless safe_model_class.respond_to?(:columns_hash)
41+
42+
discoverable_columns.each do |column_name, column|
43+
process_column(column_name, column)
44+
end
45+
46+
discover_tags
47+
discover_rich_texts
48+
end
49+
50+
# Discovers and configures associations as fields
51+
def discover_associations(only: nil, except: nil, **field_options)
52+
setup_discovery_options(only, except, field_options)
53+
return unless safe_model_class.respond_to?(:reflections)
54+
55+
discover_attachments
56+
discover_basic_associations
57+
end
58+
59+
private
60+
61+
def setup_discovery_options(only, except, field_options)
62+
@only = only
63+
@except = except
64+
@field_options = field_options
65+
end
66+
67+
def discoverable_columns
68+
model_db_columns.reject do |column_name, _|
69+
skip_column?(column_name)
70+
end
71+
end
72+
73+
def skip_column?(column_name)
74+
!column_in_scope?(column_name) ||
75+
reflections.key?(column_name) ||
76+
rich_text_column?(column_name)
77+
end
78+
79+
def rich_text_column?(column_name)
80+
rich_texts.key?(:"rich_text_#{column_name}")
81+
end
82+
83+
def process_column(column_name, column)
84+
field_config = determine_field_config(column_name, column)
85+
return unless field_config
86+
87+
create_field(column_name, field_config)
88+
end
89+
90+
def create_field(column_name, field_config)
91+
field_options = {as: field_config.dup.delete(:field).to_sym}.merge(field_config)
92+
field(column_name, **field_options.symbolize_keys, **@field_options.symbolize_keys)
93+
end
94+
95+
def create_attachment_field(association_name, reflection)
96+
field_name = association_name&.to_s&.delete_suffix("_attachment")&.to_sym || association_name
97+
field_type = determine_attachment_field_type(reflection)
98+
field(field_name, as: field_type, **@field_options)
99+
end
100+
101+
def determine_attachment_field_type(reflection)
102+
(
103+
reflection.is_a?(ActiveRecord::Reflection::HasOneReflection) ||
104+
reflection.is_a?(ActiveStorage::Reflection::HasOneAttachedReflection)
105+
) ? :file : :files
106+
end
107+
108+
def create_association_field(association_name, reflection)
109+
options = base_association_options(reflection)
110+
options.merge!(polymorphic_options(reflection)) if reflection.options[:polymorphic]
111+
112+
field(association_name, **options, **@field_options)
113+
end
114+
115+
def base_association_options(reflection)
116+
{
117+
as: reflection.macro,
118+
searchable: true,
119+
sortable: true
120+
}
121+
end
122+
123+
# Fetches the model class, falling back to the items_holder parent record in certain instances
124+
# (e.g. in the context of the sidebar)
125+
def safe_model_class
126+
respond_to?(:model_class) ? model_class : @items_holder.parent.model_class
127+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
128+
nil
129+
end
130+
131+
def model_enums
132+
@model_enums ||= if safe_model_class.respond_to?(:defined_enums)
133+
safe_model_class.defined_enums.transform_values do |enum|
134+
{
135+
field: :select,
136+
enum:
137+
}
138+
end
139+
else
140+
{}
141+
end.with_indifferent_access
142+
end
143+
144+
# Determines if a column is included in the discovery scope.
145+
# A column is in scope if it's included in `only` and not in `except`.
146+
def column_in_scope?(column_name)
147+
(!@only || @only.include?(column_name)) && (!@except || !@except.include?(column_name))
148+
end
149+
150+
def determine_field_config(attribute, column)
151+
model_enums[attribute.to_s] ||
152+
self.class.column_names_mapping[attribute] ||
153+
self.class.column_types_mapping[column.type]
154+
end
155+
156+
def discover_by_type(associations, as_type)
157+
associations.each_key do |association_name|
158+
next unless column_in_scope?(association_name)
159+
160+
field association_name, as: as_type, **@field_options.merge(name: yield(association_name))
161+
end
162+
end
163+
164+
def discover_rich_texts
165+
rich_texts.each_key do |association_name|
166+
next unless column_in_scope?(association_name)
167+
168+
field_name = association_name&.to_s&.delete_prefix("rich_text_")&.to_sym || association_name
169+
field field_name, as: :trix, **@field_options
170+
end
171+
end
172+
173+
def discover_tags
174+
tags.each_key do |association_name|
175+
next unless column_in_scope?(association_name)
176+
177+
field(
178+
tag_field_name(association_name), as: :tags,
179+
acts_as_taggable_on: tag_field_name(association_name),
180+
**@field_options
181+
)
182+
end
183+
end
184+
185+
def tag_field_name(association_name)
186+
association_name&.to_s&.delete_suffix("_taggings")&.pluralize&.to_sym || association_name
187+
end
188+
189+
def discover_attachments
190+
attachment_associations.each do |association_name, reflection|
191+
next unless column_in_scope?(association_name)
192+
193+
create_attachment_field(association_name, reflection)
194+
end
195+
end
196+
197+
def discover_basic_associations
198+
associations.each do |association_name, reflection|
199+
next unless column_in_scope?(association_name)
200+
201+
create_association_field(association_name, reflection)
202+
end
203+
end
204+
205+
def polymorphic_options(reflection)
206+
{polymorphic_as: reflection.name, types: detect_polymorphic_types(reflection)}
207+
end
208+
209+
def detect_polymorphic_types(reflection)
210+
ApplicationRecord.descendants.select { |klass| klass.reflections[reflection.plural_name] }
211+
end
212+
213+
def reflections
214+
@reflections ||= safe_model_class.reflections.symbolize_keys.reject do |name, _|
215+
ignore_reflection?(name.to_s)
216+
end
217+
end
218+
219+
def attachment_associations
220+
@attachment_associations ||= reflections.select { |_, r| r.options[:class_name] == "ActiveStorage::Attachment" }
221+
end
222+
223+
def rich_texts
224+
@rich_texts ||= reflections.select { |_, r| r.options[:class_name] == "ActionText::RichText" }
225+
end
226+
227+
def tags
228+
@tags ||= reflections.select { |_, r| r.options[:as] == :taggable }
229+
end
230+
231+
def associations
232+
@associations ||= reflections.reject do |key|
233+
attachment_associations.key?(key) || tags.key?(key) || rich_texts.key?(key)
234+
end
235+
end
236+
237+
def ignore_reflection?(name)
238+
%w[blob blobs tags].include?(name.split("_").pop) || name.to_sym == :taggings
239+
end
240+
end
241+
end
242+
end
243+
# rubocop:enable Metrics/ModuleLength

lib/avo/configuration.rb

+4
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ class Configuration
5757
attr_accessor :search_results_count
5858
attr_accessor :first_sorting_option
5959
attr_accessor :associations_lookup_list_limit
60+
attr_accessor :column_names_mapping
61+
attr_accessor :column_types_mapping
6062

6163
def initialize
6264
@root_path = "/avo"
@@ -124,6 +126,8 @@ def initialize
124126
@first_sorting_option = :desc # :desc or :asc
125127
@associations_lookup_list_limit = 1000
126128
@exclude_from_status = []
129+
@column_names_mapping = {}
130+
@column_types_mapping = {}
127131
@resource_row_controls_config = {}
128132
end
129133

lib/avo/resources/base.rb

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ class Base
44
extend ActiveSupport::DescendantsTracker
55

66
include ActionView::Helpers::UrlHelper
7+
include Avo::Concerns::HasFieldDiscovery
78
include Avo::Concerns::HasItems
89
include Avo::Concerns::CanReplaceItems
910
include Avo::Concerns::HasControls

lib/avo/resources/items/sidebar.rb

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
class Avo::Resources::Items::Sidebar
22
prepend Avo::Concerns::IsResourceItem
33

4+
include Avo::Concerns::HasFieldDiscovery
45
include Avo::Concerns::HasItems
56
include Avo::Concerns::HasItemType
67
include Avo::Concerns::IsVisible
@@ -26,6 +27,7 @@ def panel_wrapper?
2627

2728
class Builder
2829
include Avo::Concerns::BorrowItemsHolder
30+
include Avo::Concerns::HasFieldDiscovery
2931

3032
delegate :field, to: :items_holder
3133
delegate :tool, to: :items_holder

spec/dummy/app/avo/resources/compact_user.rb

+3-7
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,11 @@ class Avo::Resources::CompactUser < Avo::BaseResource
66

77
def fields
88
field :personal_information, as: :heading
9-
10-
field :first_name, as: :text
11-
field :last_name, as: :text
12-
field :birthday, as: :date
9+
discover_columns only: [:first_name, :last_name, :birthday]
1310

1411
field :heading, as: :heading, label: "Contact"
12+
discover_columns only: [:email]
1513

16-
field :email, as: :text
17-
18-
field :posts, as: :has_many
14+
discover_associations only: [:posts]
1915
end
2016
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
class Avo::Resources::FieldDiscoveryUser < Avo::BaseResource
2+
self.model_class = ::User
3+
self.description = "This is a resource with discovered fields. It will show fields and associations as defined in the model."
4+
self.find_record_method = -> {
5+
query.friendly.find id
6+
}
7+
8+
def fields
9+
main_panel do
10+
discover_columns except: %i[email active is_admin? birthday is_writer outside_link custom_css]
11+
discover_associations only: %i[cv_attachment]
12+
13+
sidebar do
14+
with_options only_on: :show do
15+
discover_columns only: %i[email], as: :gravatar, link_to_record: true, as_avatar: :circle
16+
field :heading, as: :heading, label: ""
17+
discover_columns only: %i[active], name: "Is active"
18+
end
19+
20+
discover_columns only: %i[birthday]
21+
22+
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>.'
23+
field :password_confirmation, as: :password, name: "Password confirmation", required: false, revealable: true
24+
25+
with_options only_on: :forms do
26+
field :dev, as: :heading, label: '<div class="underline uppercase font-bold">DEV</div>', as_html: true
27+
discover_columns only: %i[custom_css]
28+
end
29+
end
30+
end
31+
32+
discover_associations only: %i[posts]
33+
discover_associations except: %i[posts post cv_attachment]
34+
end
35+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# This controller has been generated to enable Rails' resource routes.
2+
# More information on https://docs.avohq.io/3.0/controllers.html
3+
class Avo::FieldDiscoveryUsersController < Avo::ResourcesController
4+
end

spec/dummy/config/initializers/avo.rb

+4
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@
100100
# type: :countless
101101
# }
102102
# end
103+
104+
config.column_names_mapping = {
105+
custom_css: {field: "code"}
106+
}
103107
end
104108

105109
if defined?(Avo::DynamicFilters)

0 commit comments

Comments
 (0)