diff --git a/.github/workflows/i18n-tests.yml b/.github/workflows/i18n-tests.yml index 4d7fd49842..58fe52afcd 100644 --- a/.github/workflows/i18n-tests.yml +++ b/.github/workflows/i18n-tests.yml @@ -106,12 +106,12 @@ jobs: id: run_tests run: bundle exec rspec spec/system/i18n_spec.rb - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: coverage_system_${{ matrix.rails }}_ruby_${{ matrix.ruby }} path: coverage/.resultset.json - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() && steps.run_tests.outcome == 'failure' with: name: rspec_failed_screenshots_rails_${{ matrix.rails }}_ruby_${{ matrix.ruby }} diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 66a06de509..91193e02ab 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -111,12 +111,12 @@ jobs: id: run_tests run: bundle exec rspec spec/system/ --tag=~i18n - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: coverage_system_${{ matrix.rails }}_ruby_${{ matrix.ruby }} path: coverage/.resultset.json - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() && steps.run_tests.outcome == 'failure' with: name: rspec_failed_screenshots_rails_${{ matrix.rails }}_ruby_${{ matrix.ruby }} diff --git a/Gemfile.lock b/Gemfile.lock index 8c974b7b25..e407726630 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - avo (3.16.2) + avo (3.17.2) actionview (>= 6.1) active_link_to activerecord (>= 6.1) diff --git a/app/components/avo/actions_component.rb b/app/components/avo/actions_component.rb index 2e809a809c..9acb59f524 100644 --- a/app/components/avo/actions_component.rb +++ b/app/components/avo/actions_component.rb @@ -102,7 +102,8 @@ def action_data_attributes(action) disabled: action.disabled?, turbo_prefetch: false, enabled_classes: "text-black", - disabled_classes: "text-gray-500" + disabled_classes: "text-gray-500", + resource_name: action.resource.model_key } end diff --git a/app/components/avo/asset_manager/javascript_component.html.erb b/app/components/avo/asset_manager/javascript_component.html.erb index 41439d7d39..973877b3f3 100644 --- a/app/components/avo/asset_manager/javascript_component.html.erb +++ b/app/components/avo/asset_manager/javascript_component.html.erb @@ -8,4 +8,5 @@ <% end %> <%# This is the last script to run so it can register custom StimulusJS controllers from plugins. %> -<%= javascript_include_tag 'late-registration', "data-turbo-track": "reload", defer: true %> +<% path = Avo::PACKED ? '/avo-assets/late-registration' : 'late-registration' %> +<%= javascript_include_tag path, "data-turbo-track": "reload", defer: true %> diff --git a/app/components/avo/media_library/list_component.rb b/app/components/avo/media_library/list_component.rb index b9b06a7aeb..c085473916 100644 --- a/app/components/avo/media_library/list_component.rb +++ b/app/components/avo/media_library/list_component.rb @@ -18,7 +18,7 @@ def controller = Avo::Current.view_context.controller def query ActiveStorage::Blob.includes(:attachments) # ignore blobs who are just a variant to avoid "n+1" blob creation - .where.not(id: ActiveStorage::Attachment.where(record_type: "ActiveStorage::VariantRecord").pluck(:blob_id)) + .where.not(id: ActiveStorage::Attachment.where(record_type: "ActiveStorage::VariantRecord").select(:blob_id)) .order(created_at: :desc) end diff --git a/app/components/avo/media_library/list_item_component.rb b/app/components/avo/media_library/list_item_component.rb index 872b270fa8..5342f8c8ec 100644 --- a/app/components/avo/media_library/list_item_component.rb +++ b/app/components/avo/media_library/list_item_component.rb @@ -15,7 +15,8 @@ def data component: component_name, blob_id: blob.id, media_library_blob_param: blob.as_json, - media_library_path_param: helpers.main_app.url_for(blob), + media_library_path_param: helpers.main_app.rails_blob_path(blob), + media_library_url_param: helpers.main_app.url_for(blob), media_library_attaching_param: @attaching, media_library_multiple_param: @multiple, media_library_selected_item: params[:controller_selector], diff --git a/app/components/avo/resource_component.rb b/app/components/avo/resource_component.rb index 56548c3fb6..36b9c48ad3 100644 --- a/app/components/avo/resource_component.rb +++ b/app/components/avo/resource_component.rb @@ -301,7 +301,8 @@ def render_action(action) turbo_prefetch: false, # When action has record present behave as standalone and keep always active. "actions-picker-target": (action.action.standalone || action.action.record.present?) ? "standaloneAction" : "resourceAction", - disabled: action.action.disabled? + disabled: action.action.disabled?, + resource_name: action.action.resource.model_key } do action.label end diff --git a/app/components/avo/sidebar_component.html.erb b/app/components/avo/sidebar_component.html.erb index 8284ebc6f7..ddaed9ae66 100644 --- a/app/components/avo/sidebar_component.html.erb +++ b/app/components/avo/sidebar_component.html.erb @@ -12,8 +12,6 @@
<%= render Avo::Sidebar::LinkComponent.new label: 'Get started', path: helpers.avo.root_path, active: :exclusive if Rails.env.development? && Avo.configuration.home_path.nil? %> - <%= render Avo::Sidebar::LinkComponent.new label: 'Media Library', path: helpers.avo.media_library_index_path, active: :exclusive if Avo::MediaLibrary.configuration.visible? %> - <% if Avo.plugin_manager.installed?(:avo_menu) && Avo.has_main_menu? %> <% Avo.main_menu.items.each do |item| %> <%= render Avo::Sidebar::ItemSwitcherComponent.new item: item %> @@ -53,6 +51,10 @@
<% end %> + + <% if Avo::MediaLibrary.configuration.visible? %> + <%= render Avo::Sidebar::LinkComponent.new label: 'Media Library', path: helpers.avo.media_library_index_path, active: :exclusive %> + <% end %> <% end %> <%= render partial: "/avo/partials/sidebar_extra" %> diff --git a/app/components/avo/tab_content_component.html.erb b/app/components/avo/tab_content_component.html.erb new file mode 100644 index 0000000000..20dae1533a --- /dev/null +++ b/app/components/avo/tab_content_component.html.erb @@ -0,0 +1,5 @@ +
+ <% @tab.visible_items.each do |item| %> + <%= render Avo::Items::SwitcherComponent.new item: item, **@kwargs %> + <% end %> +
diff --git a/app/components/avo/tab_content_component.rb b/app/components/avo/tab_content_component.rb new file mode 100644 index 0000000000..d1dab85b15 --- /dev/null +++ b/app/components/avo/tab_content_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Avo::TabContentComponent < Avo::BaseComponent + prop :tab + prop :kwargs, kind: :** +end diff --git a/app/components/avo/tab_group_component.html.erb b/app/components/avo/tab_group_component.html.erb index f14a7bd888..d5739d1f58 100644 --- a/app/components/avo/tab_group_component.html.erb +++ b/app/components/avo/tab_group_component.html.erb @@ -14,11 +14,17 @@
<%= render Avo::TabSwitcherComponent.new resource: resource, current_tab: visible_tabs.first, group: group, active_tab_name: tab.name, view: view %> <% if !tab.is_empty? %> -
- <% tab.visible_items.each do |item| %> - <%= render Avo::Items::SwitcherComponent.new resource: resource, item: item, index: index, form: form, view: @view %> + <% if tab.lazy_load && view.display? %> + <%= turbo_frame_tag tab.turbo_frame_id(parent: @group), **frame_args(tab) do %> + <% if is_not_loaded?(tab) %> + <%= render Avo::LoadingComponent.new(title: tab.name) %> + <% else %> + <%= render Avo::TabContentComponent.new tab:, resource:, index:, form:, view:%> + <% end %> <% end %> -
+ <% 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