+ <% else %>
+ <%= render Avo::TabContentComponent.new tab:, resource:, index:, form:, view:%>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/components/avo/tab_group_component.rb b/app/components/avo/tab_group_component.rb
index 730d6e4ea6..9fec095e0d 100644
--- a/app/components/avo/tab_group_component.rb
+++ b/app/components/avo/tab_group_component.rb
@@ -18,6 +18,30 @@ def render?
tabs_have_content? && visible_tabs.present?
end
+ def frame_args(tab)
+ args = {
+ target: :_top,
+ class: "block"
+ }
+
+ if is_not_loaded?(tab)
+ args[:loading] = :lazy
+ args[:src] = helpers.resource_path(
+ resource: @resource,
+ record: @resource.record,
+ keep_query_params: true,
+ active_tab_name: tab.name,
+ tab_turbo_frame: tab.turbo_frame_id(parent: @group)
+ )
+ end
+
+ args
+ end
+
+ def is_not_loaded?(tab)
+ params[:tab_turbo_frame] != tab.turbo_frame_id(parent: @group)
+ end
+
def tabs_have_content?
visible_tabs.present?
end
diff --git a/app/controllers/avo/media_library_controller.rb b/app/controllers/avo/media_library_controller.rb
index 6e9c4280fe..7e76be0343 100644
--- a/app/controllers/avo/media_library_controller.rb
+++ b/app/controllers/avo/media_library_controller.rb
@@ -36,7 +36,7 @@ def blob_params
end
def authorize_access!
- raise_404 unless Avo::MediaLibrary.configuration.visible?
+ raise_404 if Avo::MediaLibrary.configuration.disabled?
end
end
end
diff --git a/app/javascript/js/controllers/fields/trix_field_controller.js b/app/javascript/js/controllers/fields/trix_field_controller.js
index a4131468d5..4156930a25 100644
--- a/app/javascript/js/controllers/fields/trix_field_controller.js
+++ b/app/javascript/js/controllers/fields/trix_field_controller.js
@@ -107,12 +107,11 @@ export default class extends Controller {
const mediaLibraryPath = new URI(`${this.rootPath.path()}/attach-media`)
mediaLibraryPath.addSearch(params)
- const mediaLibraryVisible = window.Avo.configuration.media_library.visible && window.Avo.configuration.media_library.enabled
// Add the gallery button to the toolbar
// const buttonHTML = ``
const buttonHTML = `${galleryButtonSVG}`
- if (mediaLibraryVisible && event.target.toolbarElement && event.target.toolbarElement.querySelector('.trix-button-group--file-tools')) {
+ if (window.Avo.configuration.media_library.visible && event.target.toolbarElement && event.target.toolbarElement.querySelector('.trix-button-group--file-tools')) {
event.target.toolbarElement
.querySelector('.trix-button-group--file-tools')
.insertAdjacentHTML('beforeend', buttonHTML)
diff --git a/app/javascript/js/controllers/item_selector_controller.js b/app/javascript/js/controllers/item_selector_controller.js
index ac60eb61c5..5e5e7fc93c 100644
--- a/app/javascript/js/controllers/item_selector_controller.js
+++ b/app/javascript/js/controllers/item_selector_controller.js
@@ -75,19 +75,27 @@ export default class extends Controller {
enableResourceActions() {
this.actionLinks.forEach((link) => {
- link.classList.add(link.dataset.enabledClasses)
- link.classList.remove(link.dataset.disabledClasses)
- link.setAttribute('data-href', link.getAttribute('href'))
- link.dataset.disabled = false
+ // Enable only if is on the same resource context
+ // Avoiding to enable unrelated actions when selecting items on a has many table
+ if (link.dataset.resourceName === this.resourceName) {
+ link.classList.add(link.dataset.enabledClasses)
+ link.classList.remove(link.dataset.disabledClasses)
+ link.setAttribute('data-href', link.getAttribute('href'))
+ link.dataset.disabled = false
+ }
})
}
disableResourceActions() {
this.actionLinks.forEach((link) => {
- link.classList.remove(link.dataset.enabledClasses)
- link.classList.add(link.dataset.disabledClasses)
- link.setAttribute('href', link.getAttribute('data-href'))
- link.dataset.disabled = true
+ // Disable only if is on the same resource context
+ // Avoiding to disable unrelated actions when selecting items on a has many table
+ if (link.dataset.resourceName === this.resourceName) {
+ link.classList.remove(link.dataset.enabledClasses)
+ link.classList.add(link.dataset.disabledClasses)
+ link.setAttribute('href', link.getAttribute('data-href'))
+ link.dataset.disabled = true
+ }
})
}
}
diff --git a/app/javascript/js/controllers/media_library_controller.js b/app/javascript/js/controllers/media_library_controller.js
index b6746c4d02..21cdab5a7a 100644
--- a/app/javascript/js/controllers/media_library_controller.js
+++ b/app/javascript/js/controllers/media_library_controller.js
@@ -67,7 +67,7 @@ export default class extends Controller {
insertAttachments(attachments, event) {
// show an error if the controller is not found
if (!this.otherController) {
- console.error('[Avo->] The Media Library failed to find any field outlets to inject the asset.')
+ console.error(`[Avo->] The Media Library failed to find any field outlets to inject the asset. Tried selector: ${this.controllerSelectorValue} and name: ${this.controllerNameValue}`)
return
}
@@ -82,7 +82,8 @@ export default class extends Controller {
#extractMetadataFromItem(item) {
const blob = JSON.parse(item.dataset.mediaLibraryBlobParam)
const path = item.dataset.mediaLibraryPathParam
+ const url = item.dataset.mediaLibraryUrlParam
- return { blob, path }
+ return { blob, path, url }
}
}
diff --git a/app/views/avo/partials/_javascript.html.erb b/app/views/avo/partials/_javascript.html.erb
index b88831b87e..a83a7e1d4f 100644
--- a/app/views/avo/partials/_javascript.html.erb
+++ b/app/views/avo/partials/_javascript.html.erb
@@ -14,7 +14,7 @@
Avo.configuration.modal_frame_id = '<%= ::Avo::MODAL_FRAME_ID %>'
Avo.configuration.stimulus_controllers = []
Avo.configuration.media_library = {
- enabled: <%= Avo::MediaLibrary.configuration.enabled %>,
+ enabled: <%= Avo::MediaLibrary.configuration.enabled? %>,
visible: <%= Avo::MediaLibrary.configuration.visible? %>
}
<% end %>
diff --git a/app/views/layouts/avo/application.html.erb b/app/views/layouts/avo/application.html.erb
index 74c7d0b93a..00dad64f8e 100644
--- a/app/views/layouts/avo/application.html.erb
+++ b/app/views/layouts/avo/application.html.erb
@@ -13,15 +13,15 @@
<%= render partial: "avo/partials/branding" %>
<%= render partial: "avo/partials/pre_head" %>
<%= render Avo::AssetManager::StylesheetComponent.new asset_manager: Avo.asset_manager %>
- <%= stylesheet_link_tag @stylesheet_assets_path, "data-turbo-track": "reload", defer: true, as: "style" %>
- <% if Avo::PACKED %>
- <%= javascript_include_tag "/avo-assets/avo.base", "data-turbo-track": "reload", defer: true %>
- <% else %>
- <%= javascript_include_tag "avo.base", "data-turbo-track": "reload", defer: true %>
- <% if Rails.env.development? && defined?(Hotwire::Livereload) %>
- <%= javascript_include_tag "hotwire-livereload", defer: true %>
- <% end %>
+ <%= stylesheet_link_tag @stylesheet_assets_path, "data-turbo-track": "reload", as: "style" %>
+
+ <% path = Avo::PACKED ? "/avo-assets/avo.base" : "avo.base" %>
+ <%= javascript_include_tag path, "data-turbo-track": "reload", defer: true %>
+
+ <% if !Avo::PACKED && defined?(Hotwire::Livereload) %>
+ <%= javascript_include_tag "hotwire-livereload", defer: true %>
<% end %>
+
<%= render Avo::AssetManager::JavascriptComponent.new asset_manager: Avo.asset_manager %>
<%= render partial: "avo/partials/head" %>
<%= content_for :head %>
diff --git a/gemfiles/rails_6.1_ruby_3.1.4.gemfile.lock b/gemfiles/rails_6.1_ruby_3.1.4.gemfile.lock
index 99b1cfcd6a..8d2faf0cf0 100644
--- a/gemfiles/rails_6.1_ruby_3.1.4.gemfile.lock
+++ b/gemfiles/rails_6.1_ruby_3.1.4.gemfile.lock
@@ -6,7 +6,7 @@ PATH
PATH
remote: ..
specs:
- avo (3.16.2)
+ avo (3.17.2)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
diff --git a/gemfiles/rails_6.1_ruby_3.3.0.gemfile.lock b/gemfiles/rails_6.1_ruby_3.3.0.gemfile.lock
index 54c29590c2..a87be9b74a 100644
--- a/gemfiles/rails_6.1_ruby_3.3.0.gemfile.lock
+++ b/gemfiles/rails_6.1_ruby_3.3.0.gemfile.lock
@@ -6,7 +6,7 @@ PATH
PATH
remote: ..
specs:
- avo (3.16.2)
+ avo (3.17.2)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
diff --git a/gemfiles/rails_7.1_ruby_3.1.4.gemfile.lock b/gemfiles/rails_7.1_ruby_3.1.4.gemfile.lock
index 9a58110419..c0b83534dd 100644
--- a/gemfiles/rails_7.1_ruby_3.1.4.gemfile.lock
+++ b/gemfiles/rails_7.1_ruby_3.1.4.gemfile.lock
@@ -6,7 +6,7 @@ PATH
PATH
remote: ..
specs:
- avo (3.16.2)
+ avo (3.17.2)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
diff --git a/gemfiles/rails_7.1_ruby_3.3.0.gemfile.lock b/gemfiles/rails_7.1_ruby_3.3.0.gemfile.lock
index be81b3b837..d448c13a16 100644
--- a/gemfiles/rails_7.1_ruby_3.3.0.gemfile.lock
+++ b/gemfiles/rails_7.1_ruby_3.3.0.gemfile.lock
@@ -6,7 +6,7 @@ PATH
PATH
remote: ..
specs:
- avo (3.16.2)
+ avo (3.17.2)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
diff --git a/gemfiles/rails_8.0_ruby_3.3.0.gemfile.lock b/gemfiles/rails_8.0_ruby_3.3.0.gemfile.lock
index e31a607429..3075ffc7e7 100644
--- a/gemfiles/rails_8.0_ruby_3.3.0.gemfile.lock
+++ b/gemfiles/rails_8.0_ruby_3.3.0.gemfile.lock
@@ -6,7 +6,7 @@ PATH
PATH
remote: ..
specs:
- avo (3.16.2)
+ avo (3.17.2)
actionview (>= 6.1)
active_link_to
activerecord (>= 6.1)
diff --git a/lib/avo/base_action.rb b/lib/avo/base_action.rb
index a444c9567c..bc3958e944 100644
--- a/lib/avo/base_action.rb
+++ b/lib/avo/base_action.rb
@@ -10,7 +10,13 @@ class BaseAction
class_attribute :cancel_button_label
class_attribute :no_confirmation, default: false
class_attribute :standalone, default: false
- class_attribute :visible
+ class_attribute :visible, default: -> {
+ # Hide on the :new view by default
+ return false if view.new?
+
+ # Show on all other views
+ true
+ }
class_attribute :may_download_file
class_attribute :turbo
class_attribute :authorize, default: true
@@ -207,17 +213,6 @@ def handle_action(**args)
end
def visible_in_view(parent_resource: nil)
- return false unless authorized?
-
- if visible.blank?
- # Hide on the :new view by default
- return false if view.new?
-
- # Show on all other views
- return true
- end
-
- # Run the visible block if available
Avo::ExecutionContext.new(
target: visible,
params: params,
@@ -225,7 +220,7 @@ def visible_in_view(parent_resource: nil)
resource: @resource,
view: @view,
arguments: arguments
- ).handle
+ ).handle && authorized?
end
def succeed(text)
diff --git a/lib/avo/concerns/has_field_discovery.rb b/lib/avo/concerns/has_field_discovery.rb
new file mode 100644
index 0000000000..46ffc863b4
--- /dev/null
+++ b/lib/avo/concerns/has_field_discovery.rb
@@ -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
diff --git a/lib/avo/configuration.rb b/lib/avo/configuration.rb
index ed5a144449..42a3ba6f0a 100644
--- a/lib/avo/configuration.rb
+++ b/lib/avo/configuration.rb
@@ -57,6 +57,8 @@ class Configuration
attr_accessor :search_results_count
attr_accessor :first_sorting_option
attr_accessor :associations_lookup_list_limit
+ attr_accessor :column_names_mapping
+ attr_accessor :column_types_mapping
def initialize
@root_path = "/avo"
@@ -124,6 +126,8 @@ def initialize
@first_sorting_option = :desc # :desc or :asc
@associations_lookup_list_limit = 1000
@exclude_from_status = []
+ @column_names_mapping = {}
+ @column_types_mapping = {}
@resource_row_controls_config = {}
end
diff --git a/lib/avo/media_library/configuration.rb b/lib/avo/media_library/configuration.rb
index 5d796caca5..8263e2d4b4 100644
--- a/lib/avo/media_library/configuration.rb
+++ b/lib/avo/media_library/configuration.rb
@@ -7,8 +7,16 @@ class Configuration
config_accessor(:enabled) { false }
def visible?
+ return false if disabled?
+
Avo::ExecutionContext.new(target: config[:visible]).handle
end
+
+ def enabled?
+ Avo::ExecutionContext.new(target: config[:enabled]).handle
+ end
+
+ def disabled? = !enabled?
end
def self.configuration
diff --git a/lib/avo/resources/base.rb b/lib/avo/resources/base.rb
index b455e25f93..e24a9e914c 100644
--- a/lib/avo/resources/base.rb
+++ b/lib/avo/resources/base.rb
@@ -4,6 +4,7 @@ class Base
extend ActiveSupport::DescendantsTracker
include ActionView::Helpers::UrlHelper
+ include Avo::Concerns::HasFieldDiscovery
include Avo::Concerns::HasItems
include Avo::Concerns::CanReplaceItems
include Avo::Concerns::HasControls
diff --git a/lib/avo/resources/items/sidebar.rb b/lib/avo/resources/items/sidebar.rb
index 0e894cfd88..e1927e3d2a 100644
--- a/lib/avo/resources/items/sidebar.rb
+++ b/lib/avo/resources/items/sidebar.rb
@@ -1,6 +1,7 @@
class Avo::Resources::Items::Sidebar
prepend Avo::Concerns::IsResourceItem
+ include Avo::Concerns::HasFieldDiscovery
include Avo::Concerns::HasItems
include Avo::Concerns::HasItemType
include Avo::Concerns::IsVisible
@@ -26,6 +27,7 @@ def panel_wrapper?
class Builder
include Avo::Concerns::BorrowItemsHolder
+ include Avo::Concerns::HasFieldDiscovery
delegate :field, to: :items_holder
delegate :tool, to: :items_holder
diff --git a/lib/avo/resources/items/tab.rb b/lib/avo/resources/items/tab.rb
index fad0a245fe..0e5ab610e8 100644
--- a/lib/avo/resources/items/tab.rb
+++ b/lib/avo/resources/items/tab.rb
@@ -10,6 +10,7 @@ class Avo::Resources::Items::Tab
delegate :items, :add_item, to: :items_holder
attr_accessor :description
+ attr_reader :lazy_load
def initialize(name: nil, description: nil, view: nil, **args)
@name = name
@@ -18,6 +19,7 @@ def initialize(name: nil, description: nil, view: nil, **args)
@view = Avo::ViewInquirer.new view
@args = args
@visible = args[:visible]
+ @lazy_load = args[:lazy_load]
post_initialize if respond_to?(:post_initialize)
end
diff --git a/lib/avo/version.rb b/lib/avo/version.rb
index 3e570e8efa..527d939851 100644
--- a/lib/avo/version.rb
+++ b/lib/avo/version.rb
@@ -1,3 +1,3 @@
module Avo
- VERSION = "3.16.2" unless const_defined?(:VERSION)
+ VERSION = "3.17.2" unless const_defined?(:VERSION)
end
diff --git a/lib/tasks/avo_tasks.rake b/lib/tasks/avo_tasks.rake
index 75bde4797a..b4069dfbeb 100644
--- a/lib/tasks/avo_tasks.rake
+++ b/lib/tasks/avo_tasks.rake
@@ -133,6 +133,7 @@ end
desc "Installs yarn dependencies for Avo"
task "avo:yarn_install" do
# tailwind.preset.js needs this dependencies in order to be required
+ # Ensure that versions remain updated and synchronized with those specified in package.json.
puts "[Avo->] Adding yarn dependencies"
- `yarn add tailwindcss @tailwindcss/forms @tailwindcss/typography --cwd #{Avo::Engine.root}`
+ `yarn add tailwindcss@^3.4.17 @tailwindcss/forms@^0.5.10 @tailwindcss/typography@^0.5.16 @tailwindcss/container-queries@^0.1.1 --cwd #{Avo::Engine.root}`
end
diff --git a/spec/dummy/app/avo/resources/compact_user.rb b/spec/dummy/app/avo/resources/compact_user.rb
index fcb7995efe..619abdda4a 100644
--- a/spec/dummy/app/avo/resources/compact_user.rb
+++ b/spec/dummy/app/avo/resources/compact_user.rb
@@ -6,15 +6,11 @@ class Avo::Resources::CompactUser < Avo::BaseResource
def fields
field :personal_information, as: :heading
-
- field :first_name, as: :text
- field :last_name, as: :text
- field :birthday, as: :date
+ discover_columns only: [:first_name, :last_name, :birthday]
field :heading, as: :heading, label: "Contact"
+ discover_columns only: [:email]
- field :email, as: :text
-
- field :posts, as: :has_many
+ discover_associations only: [:posts]
end
end
diff --git a/spec/dummy/app/avo/resources/field_discovery_user.rb b/spec/dummy/app/avo/resources/field_discovery_user.rb
new file mode 100644
index 0000000000..ec05c17daf
--- /dev/null
+++ b/spec/dummy/app/avo/resources/field_discovery_user.rb
@@ -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 here.'
+ field :password_confirmation, as: :password, name: "Password confirmation", required: false, revealable: true
+
+ with_options only_on: :forms do
+ field :dev, as: :heading, label: '
DEV
', 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
diff --git a/spec/dummy/app/avo/resources/person.rb b/spec/dummy/app/avo/resources/person.rb
index 2fe4638dd8..544c408c0f 100644
--- a/spec/dummy/app/avo/resources/person.rb
+++ b/spec/dummy/app/avo/resources/person.rb
@@ -24,5 +24,86 @@ def fields
as: :has_many,
hide_search_input: true,
description: "Default behaviour with link_to_child_resource disabled"
+
+ tabs do
+ tab "Employment" do
+ panel do
+ field :job_title, as: :heading do
+ "Software Engineer"
+ end
+
+ row do
+ field :company, stacked: true do
+ "TechCorp Inc."
+ end
+ field :department, stacked: true do
+ "Research & Development"
+ end
+ end
+
+ field :years_of_experience do
+ "7 Years"
+ end
+
+ sidebar do
+ field :employee_id do
+ "EMP123456"
+ end
+ field :supervisor do
+ "Jane Smith"
+ end
+ end
+ end
+ end
+
+ tab "Address", lazy_load: true do
+ panel do
+ field :address, as: :heading
+ row do
+ field :street_address, stacked: true do
+ "1234 Elm Street"
+ end
+ field :city, stacked: true do
+ "Los Angeles"
+ end
+ end
+
+ field :state do
+ "California"
+ end
+
+ sidebar do
+ field :phone_number do
+ "+1 (555) 123-4567"
+ end
+ field :zip_code do
+ "90001"
+ end
+ end
+ end
+ end
+
+ tab "Preferences" do
+ panel do
+ field :preferred_language do
+ "English"
+ end
+
+ field :theme_mode do
+ "Dark Mode"
+ end
+
+ field :notification_preference do
+ "Email & SMS"
+ end
+
+ sidebar do
+ field :timezone do
+ "Pacific Time (PST)"
+ end
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb b/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb
new file mode 100644
index 0000000000..4ef6e31b11
--- /dev/null
+++ b/spec/dummy/app/controllers/avo/field_discovery_users_controller.rb
@@ -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
diff --git a/spec/dummy/config/initializers/avo.rb b/spec/dummy/config/initializers/avo.rb
index 715a034407..063fd18fba 100644
--- a/spec/dummy/config/initializers/avo.rb
+++ b/spec/dummy/config/initializers/avo.rb
@@ -100,6 +100,10 @@
# type: :countless
# }
# end
+
+ config.column_names_mapping = {
+ custom_css: {field: "code"}
+ }
end
if defined?(Avo::DynamicFilters)
diff --git a/spec/system/avo/has_field_discovery_spec.rb b/spec/system/avo/has_field_discovery_spec.rb
new file mode 100644
index 0000000000..e5ab6bca0a
--- /dev/null
+++ b/spec/system/avo/has_field_discovery_spec.rb
@@ -0,0 +1,298 @@
+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" }
+
+ before do
+ Avo::Resources::User.with_temporary_items do
+ 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 here.'
+ field :password_confirmation, as: :password, name: "Password confirmation", required: false, revealable: true
+
+ with_options only_on: :forms do
+ field :dev, as: :heading, label: '
DEV
', 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
+
+ after do
+ Avo::Resources::User.restore_items_from_backup
+ end
+
+ describe "Show Page" do
+ let(:url) { "/admin/resources/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
+
+ expect(page).to have_selector("#has_many_field_show_posts")
+ expect(page).to have_selector("#has_many_field_show_posts")
+ expect(page).to have_selector("#has_many_field_show_people")
+ expect(page).to have_selector("#has_many_field_show_spouses")
+ expect(page).to have_selector("#has_many_field_show_comments")
+ expect(page).to have_selector("#has_and_belongs_to_many_field_show_projects")
+ expect(page).to have_selector("#has_many_field_show_team_memberships")
+ expect(page).to have_selector("#has_many_field_show_teams")
+
+ # Verify `cv_attachment` association is present
+ expect(page).to have_text "CV"
+ end
+
+ it "renders each field exactly once" do
+ wait_for_loaded
+
+ within(".main-content-area") do
+ within("[data-panel-id='main']") do
+ # Basic fields
+ ## Main Panel
+ expect(page).to have_text("FIRST NAME", count: 1)
+ expect(page).to have_text("LAST NAME", count: 1)
+ expect(page).to have_text("ROLES", count: 1)
+ expect(page).to have_text("TEAM ID", count: 1)
+ expect(page).to have_text("CREATED AT", count: 1)
+ expect(page).to have_text("UPDATED AT", count: 1)
+ expect(page).to have_text("SLUG", count: 1)
+
+ # Sidebar
+ expect(page).to have_text("AVATAR", count: 1)
+ expect(page).to have_text("IS ACTIVE", count: 1)
+ expect(page).to have_text("BIRTHDAY", count: 1)
+
+ # Single file uploads
+ expect(page).to have_text("CV", count: 1)
+ expect(page).not_to have_text("CV ATTACHMENT")
+ end
+
+ # Associations
+ expect(page).to have_selector("#has_many_field_show_posts", count: 1)
+ end
+ end
+ end
+
+ describe "Index Page" do
+ let(:url) { "/admin/resources/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/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
+
+ it "renders each input field exactly once" do
+ wait_for_loaded
+
+ # Form fields
+ expect(page).to have_text("FIRST NAME", count: 1)
+ expect(page).to have_text("LAST NAME", count: 1)
+ expect(page).to have_text("ROLES", count: 1)
+ expect(page).to have_text("TEAM ID", count: 1)
+ expect(page).to have_text("CREATED AT", count: 1)
+ expect(page).to have_text("UPDATED AT", count: 1)
+ expect(page).to have_text("SLUG", count: 1)
+
+ # File upload fields
+ expect(page).to have_text("CV", count: 1)
+
+ # Password fields
+ expect(page).to have_text("USER PASSWORD", count: 1)
+ expect(page).to have_text("PASSWORD CONFIRMATION", count: 1)
+ end
+ end
+
+ describe "Has One Attachment" do
+ let(:url) { "/admin/resources/users/#{user.id}/edit" }
+
+ before { visit url }
+
+ it "displays single file upload correctly for has_one_attached" do
+ wait_for_loaded
+
+ within('[data-field-id="cv"]') do
+ # Verify it shows "Choose File" instead of "Choose Files"
+ expect(page).to have_css('input[type="file"]:not([multiple])')
+ end
+ end
+ end
+
+ describe "Trix Editor" do
+ let(:event) { create :event }
+ let(:url) { "/admin/resources/events/#{event.id}/edit" }
+
+ after do
+ Avo::Resources::Event.restore_items_from_backup
+ end
+
+ before do
+ Avo::Resources::Event.with_temporary_items do
+ discover_columns
+ end
+ visit url
+ end
+
+ it "renders Trix editor only once" do
+ wait_for_loaded
+
+ # Verify only one Trix editor instance is present
+ expect(page).to have_css("trix-editor", count: 1)
+ end
+ end
+
+ describe "Tags" do
+ let(:post) { create :post }
+ let(:url) { "/admin/resources/posts/#{post.id}" }
+
+ after do
+ Avo::Resources::Post.restore_items_from_backup
+ end
+
+ before do
+ Avo::Resources::Post.with_temporary_items do
+ discover_columns
+ end
+ visit url
+ end
+
+ it "renders tags correctly" do
+ wait_for_loaded
+
+ # Verify only one Trix editor instance is present
+ expect(page).to have_text("TAGS", count: 1)
+ expect(page).to have_css('[data-target="tag-component"]')
+ end
+ end
+
+ describe "Enum Fields" do
+ let(:post) { create :post }
+ let(:url) { "/admin/resources/posts/#{post.id}/edit" }
+
+ after do
+ Avo::Resources::Post.restore_items_from_backup
+ end
+
+ before do
+ Avo::Resources::Post.with_temporary_items do
+ discover_columns
+ end
+ visit url
+ end
+
+ it "displays enum fields as select boxes" do
+ wait_for_loaded
+
+ within('[data-field-id="status"]') do
+ expect(page).to have_css("select")
+ expect(page).to have_select(options: ["draft", "published", "archived"])
+ expect(page).to have_select(selected: post.status)
+ end
+ end
+ end
+
+ describe "Polymorphic Associations" do
+ let(:post) { create :post }
+ let(:comment) { create :comment, commentable: post }
+
+ after do
+ Avo::Resources::Comment.restore_items_from_backup
+ end
+
+ before do
+ Avo::Resources::Comment.with_temporary_items do
+ discover_associations
+ end
+ visit "/admin/resources/comments/#{comment.id}"
+ end
+
+ it "displays polymorphic association correctly" do
+ wait_for_loaded
+
+ within("[data-panel-id='main']") do
+ expect(page).to have_text("COMMENTABLE")
+ expect(page).to have_link(post.name, href: /\/admin\/resources\/posts\//)
+ end
+ end
+ end
+
+ describe "Ignored Fields" do
+ before { visit "/admin/resources/users/#{user.slug}" }
+
+ it "does not display sensitive fields" do
+ wait_for_loaded
+
+ within("[data-panel-id='main']") do
+ expect(page).not_to have_text("ENCRYPTED_PASSWORD")
+ expect(page).not_to have_text("RESET_PASSWORD_TOKEN")
+ expect(page).not_to have_text("REMEMBER_CREATED_AT")
+ end
+ end
+ end
+end
diff --git a/spec/system/avo/tabs_spec.rb b/spec/system/avo/tabs_spec.rb
index a684e3f124..c155a704b7 100644
--- a/spec/system/avo/tabs_spec.rb
+++ b/spec/system/avo/tabs_spec.rb
@@ -230,4 +230,53 @@
Avo.configuration.persistence = {driver: nil}
end
+
+ let!(:person) { create :person }
+
+ it "lazy_load" do
+ visit avo.resources_person_path(person)
+
+ scroll_to first_tab_group
+
+ # Find visible information from first default tab
+ field_wrapper = find('div[data-field-id="company"]')
+ label = field_wrapper.find('div[data-slot="label"]')
+ value = field_wrapper.find('div[data-slot="value"]')
+ expect(label.text.strip).to eq("COMPANY")
+ expect(value.text.strip).to eq("TechCorp Inc.")
+
+ # Expect text from preferences and employment tabs (not lazy loaded) to be visible
+ within(:css, '.block.hidden[data-tabs-target="tabPanel"][data-tab-id="Preferences"]', visible: :all) do
+ # Find the field wrapper for "Notification preference"
+ field_wrapper = find('div[data-field-id="notification_preference"]', visible: :all)
+
+ # Locate the label and value within the wrapper
+ label = field_wrapper.find('div[data-slot="label"]', visible: :all)
+ value = field_wrapper.find('div[data-slot="value"]', visible: :all)
+
+ expect(label.text(:all).strip).to eq("Notification preference")
+ expect(value.text(:all).strip).to eq("Email & SMS")
+ end
+
+ # Expect not to find field from lazy loaded tab
+ within(:css, '.block.hidden[data-tabs-target="tabPanel"][data-tab-id="Address"]', visible: :all) do
+ expect(page).not_to have_selector('div[data-field-id="phone_number"]', visible: :all)
+ end
+
+ find('a[data-selected="false"][data-tabs-tab-name-param="Address"]').click
+ wait_for_loaded
+
+ # Find the phone number from lazy loaded tab after clicking on it
+ within(:css, '.block[data-tabs-target="tabPanel"][data-tab-id="Address"]', visible: :all) do
+ # Find the field wrapper for "Phone number"
+ field_wrapper = find('div[data-field-id="phone_number"]', visible: :all)
+
+ # Locate the label and value within the wrapper
+ label = field_wrapper.find('div[data-slot="label"]', visible: :all)
+ value = field_wrapper.find('div[data-slot="value"]', visible: :all)
+
+ expect(label.text(:all).strip).to eq("Phone number")
+ expect(value.text(:all).strip).to eq("+1 (555) 123-4567")
+ end
+ end
end