diff --git a/app/components/avo/index/resource_controls_component.rb b/app/components/avo/index/resource_controls_component.rb
index fad6879120..b253068293 100644
--- a/app/components/avo/index/resource_controls_component.rb
+++ b/app/components/avo/index/resource_controls_component.rb
@@ -16,6 +16,9 @@ def can_detach?
end
def can_edit?
+ # Disable edit for ArrayResources
+ return false if @resource.resource_type_array?
+
return authorize_association_for(:edit) if @reflection.present?
@resource.authorization.authorize_action(:edit, raise_exception: false)
diff --git a/app/components/avo/index/table_row_component.html.erb b/app/components/avo/index/table_row_component.html.erb
index 5668ae53e6..993d5ba9f7 100644
--- a/app/components/avo/index/table_row_component.html.erb
+++ b/app/components/avo/index/table_row_component.html.erb
@@ -41,7 +41,7 @@
parent_resource: @parent_resource
) %>
<% else %>
-
— |
+ — |
<% end %>
<% end %>
<% if @resource.resource_controls_render_on_the_right? %>
diff --git a/app/components/avo/resource_component.rb b/app/components/avo/resource_component.rb
index 3974ada360..ab4c1806e9 100644
--- a/app/components/avo/resource_component.rb
+++ b/app/components/avo/resource_component.rb
@@ -40,12 +40,18 @@ def detach_path
end
def can_see_the_edit_button?
+ # Disable edit for ArrayResources
+ return false if @resource.resource_type_array?
+
return authorize_association_for(:edit) if @reflection.present?
@resource.authorization.authorize_action(:edit, raise_exception: false)
end
def can_see_the_destroy_button?
+ # Disable destroy for ArrayResources
+ return false if @resource.resource_type_array?
+
@resource.authorization.authorize_action(:destroy, raise_exception: false)
end
diff --git a/app/components/avo/views/resource_index_component.html.erb b/app/components/avo/views/resource_index_component.html.erb
index 84bfe55c7a..25e56dc0f1 100644
--- a/app/components/avo/views/resource_index_component.html.erb
+++ b/app/components/avo/views/resource_index_component.html.erb
@@ -11,7 +11,7 @@
description: description,
cover_photo: resource.cover_photo,
data: {component: "resources-index"},
- display_breadcrumbs: @reflection.blank? || (@reflection.present? && !helpers.turbo_frame_request?)
+ display_breadcrumbs: (@reflection.blank? && @parent_resource.blank?) || (@reflection.present? && !helpers.turbo_frame_request?)
) do |c| %>
<% c.with_name_slot do %>
<%= render Avo::PanelNameComponent.new name: title, url: (params[:turbo_frame].present? && linkable?) ? field.frame_url(add_turbo_frame: false) : nil, target: :_blank do |panel_name_component| %>
diff --git a/app/components/avo/views/resource_index_component.rb b/app/components/avo/views/resource_index_component.rb
index 92122977e6..b1156e4c0e 100644
--- a/app/components/avo/views/resource_index_component.rb
+++ b/app/components/avo/views/resource_index_component.rb
@@ -40,6 +40,9 @@ def available_view_types
# The Create button is dependent on the new? policy method.
# The create? should be called only when the user clicks the Save button so the developers gets access to the params from the form.
def can_see_the_create_button?
+ # Disable creation for ArrayResources
+ return false if @resource.resource_type_array?
+
return authorize_association_for(:create) if @reflection.present?
@resource.authorization.authorize_action(:new, raise_exception: false) && !has_reflection_and_is_read_only
diff --git a/app/controllers/avo/array_controller.rb b/app/controllers/avo/array_controller.rb
new file mode 100644
index 0000000000..0b3332d34a
--- /dev/null
+++ b/app/controllers/avo/array_controller.rb
@@ -0,0 +1,7 @@
+module Avo
+ class ArrayController < BaseController
+ def set_query
+ @query ||= @resource.fetch_records
+ end
+ end
+end
diff --git a/app/controllers/avo/associations_controller.rb b/app/controllers/avo/associations_controller.rb
index 3dd24b3175..a7cea10acd 100644
--- a/app/controllers/avo/associations_controller.rb
+++ b/app/controllers/avo/associations_controller.rb
@@ -24,8 +24,20 @@ def index
@resource = @related_resource
@parent_record = @parent_resource.find_record(params[:id], params: params)
@parent_resource.hydrate(record: @parent_record)
- association_name = BaseResource.valid_association_name(@parent_record, association_from_params)
- @query = @related_authorization.apply_policy @parent_record.send(association_name)
+
+ # When array field the records are fetched from the field block, from the parent record or from the resource def records
+ # When other field type, like has_many the @query is directly fetched from the parent record
+ # Don't apply policy on array type since it can return an array of hashes where `.all` and other methods used on policy will fail.
+ @query = if @field.type == "array"
+ @resource.fetch_records(Avo::ExecutionContext.new(target: @field.block).handle || @parent_record.try(@field.id))
+ else
+ @related_authorization.apply_policy(
+ @parent_record.send(
+ BaseResource.valid_association_name(@parent_record, association_from_params)
+ )
+ )
+ end
+
@association_field = find_association_field(resource: @parent_resource, association: params[:related_name])
if @association_field.present? && @association_field.scope.present?
@@ -125,7 +137,8 @@ def set_reflection
end
def set_attachment_class
- @attachment_class = @reflection.klass
+ # @reflection is nil whe using an Array field.
+ @attachment_class = @reflection&.klass
end
def set_attachment_resource
diff --git a/app/controllers/avo/base_application_controller.rb b/app/controllers/avo/base_application_controller.rb
index 77bceae7b0..d8a77500ef 100644
--- a/app/controllers/avo/base_application_controller.rb
+++ b/app/controllers/avo/base_application_controller.rb
@@ -84,19 +84,6 @@ def resource
Avo.resource_manager.get_resource_by_controller_name @resource_name
end
- def related_resource
- # Find the field from the parent resource
- field = find_association_field(resource: @resource, association: params[:related_name])
-
- return field.use_resource if field&.use_resource.present?
-
- reflection = @record.class.reflect_on_association(field&.for_attribute || params[:related_name])
-
- reflected_model = reflection.klass
-
- Avo.resource_manager.get_resource_by_model_class reflected_model
- end
-
def set_resource_name
@resource_name = resource_name
end
@@ -118,6 +105,11 @@ def detect_fields
end
def set_related_resource
+ # Find the field from the parent resource
+ related_resource = find_association_field(resource: @resource, association: params[:related_name])
+ .hydrate(record: @record)
+ .resource_class(params)
+
raise Avo::MissingResourceError.new(related_resource_name) if related_resource.nil?
action_view = action_name.to_sym
diff --git a/app/controllers/avo/base_controller.rb b/app/controllers/avo/base_controller.rb
index e21d0c2156..8192322ba5 100644
--- a/app/controllers/avo/base_controller.rb
+++ b/app/controllers/avo/base_controller.rb
@@ -28,11 +28,7 @@ def index
set_index_params
set_filters
set_actions
-
- # If we don't get a query object predefined from a child controller like associations, just spin one up
- unless defined? @query
- @query = @resource.class.query_scope
- end
+ set_query
# Eager load the associations
if @resource.includes.present?
@@ -46,7 +42,7 @@ def index
end
end
- apply_sorting
+ apply_sorting if @index_params[:sort_by]
# Apply filters to the current query
filters_to_be_applied.each do |filter_class, filter_value|
@@ -310,18 +306,7 @@ def set_index_params
set_pagination_params
# Sorting
- if params[:sort_by].present?
- @index_params[:sort_by] = params[:sort_by]
- elsif @resource.model_class.present?
- available_columns = @resource.model_class.column_names
- default_sort_column = @resource.default_sort_column
-
- if available_columns.include?(default_sort_column.to_s)
- @index_params[:sort_by] = default_sort_column
- elsif available_columns.include?("created_at")
- @index_params[:sort_by] = :created_at
- end
- end
+ @index_params[:sort_by] = params[:sort_by] || @resource.sort_by_param
@index_params[:sort_direction] = params[:sort_direction] || @resource.default_sort_direction
@@ -608,8 +593,6 @@ def apply_pagination
end
def apply_sorting
- return if @index_params[:sort_by].nil?
-
sort_by = @index_params[:sort_by].to_sym
if sort_by != :created_at
@query = @query.unscope(:order)
@@ -650,5 +633,10 @@ def set_pagination_params
@index_params[:per_page] = cookies[:per_page] || Avo.configuration.per_page
end
+
+ # If we don't get a query object predefined from a child controller like associations, just spin one up
+ def set_query
+ @query ||= @resource.class.query_scope
+ end
end
end
diff --git a/app/views/avo/partials/_table_header.html.erb b/app/views/avo/partials/_table_header.html.erb
index f3dd5bc61c..25f7b6b0ed 100644
--- a/app/views/avo/partials/_table_header.html.erb
+++ b/app/views/avo/partials/_table_header.html.erb
@@ -28,9 +28,10 @@
<% fields.each_with_index do |field, index| %>
<%
first_option = Avo.configuration.first_sorting_option.to_s
- second_option = first_option == "desc" ? "asc" : "desc"
if params[:sort_by] == field.id.to_s
+ second_option = first_option == "desc" ? "asc" : "desc"
+
if params[:sort_direction] == second_option
sort_by = nil
else
@@ -65,7 +66,7 @@
} do %>
<%= content_tag :div,
class: "relative flex items-center justify-between w-full" do %>
- <% if field.sortable %>
+ <% if field.sortable && @resource.sorting_supported? %>
<%= link_to params.permit!.merge(sort_by: sort_by, sort_direction: sort_direction),
class: class_names("flex-1 flex justify-between", text_classes),
'data-turbo-frame': params[:turbo_frame] do %>
diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb
index 584d9a69b8..f5fe824a7d 100644
--- a/config/initializers/pagy.rb
+++ b/config/initializers/pagy.rb
@@ -1,5 +1,6 @@
require "pagy/extras/trim"
require "pagy/extras/countless"
+require "pagy/extras/array"
if ::Pagy::VERSION >= ::Gem::Version.new("9.0")
require "pagy/extras/size"
end
diff --git a/lib/avo/concerns/find_association_field.rb b/lib/avo/concerns/find_association_field.rb
index bf76279556..c66286a271 100644
--- a/lib/avo/concerns/find_association_field.rb
+++ b/lib/avo/concerns/find_association_field.rb
@@ -3,7 +3,7 @@ module Concerns
module FindAssociationField
# The supported association types are defined in the ASSOCIATIONS constant.
unless defined?(ASSOCIATIONS)
- ASSOCIATIONS = ["belongs_to", "has_one", "has_many", "has_and_belongs_to_many"]
+ ASSOCIATIONS = ["belongs_to", "has_one", "has_many", "has_and_belongs_to_many", "array"]
end
# This method is used to find an association field for a given resource.
diff --git a/lib/avo/concerns/has_items.rb b/lib/avo/concerns/has_items.rb
index 9381b73cd6..08da78d258 100644
--- a/lib/avo/concerns/has_items.rb
+++ b/lib/avo/concerns/has_items.rb
@@ -326,8 +326,11 @@ def is_standalone?(item)
def hydrate_item(item)
return unless item.respond_to? :hydrate
- res = self.class.ancestors.include?(Avo::BaseResource) ? self : resource
- item.hydrate(view: view, resource: res)
+ item.hydrate(
+ view: view,
+ # Use self when this is executed from a resource context, call resource otherwise.
+ resource: self.class.ancestors.include?(Avo::Resources::Base) ? self : resource
+ )
end
end
end
diff --git a/lib/avo/concerns/pagination.rb b/lib/avo/concerns/pagination.rb
index 784ac8a1f3..619ae39d1f 100644
--- a/lib/avo/concerns/pagination.rb
+++ b/lib/avo/concerns/pagination.rb
@@ -12,6 +12,7 @@ module Pagination
PAGINATION_METHOD = {
default: :pagy,
countless: :pagy_countless,
+ array: :pagy_array,
}
end
diff --git a/lib/avo/fields/array_field.rb b/lib/avo/fields/array_field.rb
new file mode 100644
index 0000000000..51c0cf2965
--- /dev/null
+++ b/lib/avo/fields/array_field.rb
@@ -0,0 +1,17 @@
+module Avo
+ module Fields
+ class ArrayField < ManyFrameBaseField
+ def translated_name(default:)
+ t(translation_key, count: 2, default: default_name).capitalize
+ end
+
+ def view_component_name
+ "HasManyField"
+ end
+
+ def resource_class(params)
+ use_resource || Avo.resource_manager.get_resource_by_name(@id.to_s)
+ end
+ end
+ end
+end
diff --git a/lib/avo/fields/belongs_to_field.rb b/lib/avo/fields/belongs_to_field.rb
index b8910292a0..95e0b96897 100644
--- a/lib/avo/fields/belongs_to_field.rb
+++ b/lib/avo/fields/belongs_to_field.rb
@@ -63,7 +63,6 @@ class BelongsToField < BaseField
attr_accessor :target
attr_reader :polymorphic_as
- attr_reader :relation_method
attr_reader :types # for Polymorphic associations
attr_reader :allow_via_detaching
attr_reader :attach_scope
@@ -96,7 +95,7 @@ def value
super(polymorphic_as)
else
# Get the value from the pre-filled association record
- super(relation_method)
+ super(@relation_method)
end
end
diff --git a/lib/avo/fields/has_base_field.rb b/lib/avo/fields/frame_base_field.rb
similarity index 69%
rename from lib/avo/fields/has_base_field.rb
rename to lib/avo/fields/frame_base_field.rb
index 2e32c0ba10..aca2977aaa 100644
--- a/lib/avo/fields/has_base_field.rb
+++ b/lib/avo/fields/frame_base_field.rb
@@ -1,35 +1,18 @@
module Avo
module Fields
- class HasBaseField < BaseField
- include Avo::Fields::Concerns::IsSearchable
+ class FrameBaseField < BaseField
include Avo::Fields::Concerns::UseResource
include Avo::Fields::Concerns::ReloadIcon
include Avo::Fields::Concerns::LinkableTitle
-
- attr_accessor :display
- attr_accessor :scope
- attr_accessor :attach_scope
- attr_accessor :description
- attr_accessor :discreet_pagination
- attr_accessor :hide_search_input
- attr_reader :link_to_child_resource
- attr_reader :attach_fields
+ include Avo::Concerns::HasDescription
def initialize(id, **args, &block)
super(id, **args, &block)
- @scope = args[:scope].present? ? args[:scope] : nil
- @attach_scope = args[:attach_scope].present? ? args[:attach_scope] : nil
- @display = args[:display].present? ? args[:display] : :show
- @searchable = args[:searchable] == true
- @hide_search_input = args[:hide_search_input] || false
+
+ @use_resource = args[:use_resource]
+ @reloadable = args[:reloadable]
+ @linkable = args[:linkable]
@description = args[:description]
- @use_resource = args[:use_resource] || nil
- @discreet_pagination = args[:discreet_pagination] || false
- # Defaults to nil so that if not set falls back to `link_to_child_resource` defined in the resource
- @link_to_child_resource = args[:link_to_child_resource]
- @reloadable = args[:reloadable].present? ? args[:reloadable] : false
- @linkable = args[:linkable].present? ? args[:linkable] : false
- @attach_fields = args[:attach_fields]
end
def field_resource
@@ -37,7 +20,7 @@ def field_resource
end
def turbo_frame
- "#{self.class.name.demodulize.to_s.underscore}_#{display}_#{frame_id}"
+ "#{self.class.name.demodulize.to_s.underscore}_show_#{frame_id}"
end
def frame_url(add_turbo_frame: true)
@@ -122,6 +105,18 @@ def query_params(add_turbo_frame: true)
}.compact
end
+ def resource_class(params)
+ return use_resource if use_resource.present?
+
+ return Avo.resource_manager.get_resource_by_name @id.to_s if @array
+
+ reflection = @record.class.reflect_on_association(@for_attribute || params[:related_name])
+
+ reflected_model = reflection.klass
+
+ Avo.resource_manager.get_resource_by_model_class reflected_model
+ end
+
private
def frame_id
diff --git a/lib/avo/fields/has_and_belongs_to_many_field.rb b/lib/avo/fields/has_and_belongs_to_many_field.rb
index 9ec327992c..d616cc2ffe 100644
--- a/lib/avo/fields/has_and_belongs_to_many_field.rb
+++ b/lib/avo/fields/has_and_belongs_to_many_field.rb
@@ -1,14 +1,6 @@
module Avo
module Fields
- class HasAndBelongsToManyField < HasBaseField
- def initialize(id, **args, &block)
- args[:updatable] = false
-
- only_on Avo.configuration.resource_default_view
-
- super(id, **args, &block)
- end
-
+ class HasAndBelongsToManyField < HasManyBaseField
def view_component_name
"HasManyField"
end
diff --git a/lib/avo/fields/has_many_base_field.rb b/lib/avo/fields/has_many_base_field.rb
new file mode 100644
index 0000000000..439a849bcf
--- /dev/null
+++ b/lib/avo/fields/has_many_base_field.rb
@@ -0,0 +1,18 @@
+module Avo
+ module Fields
+ class HasManyBaseField < ManyFrameBaseField
+ attr_reader :attach_scope,
+ :link_to_child_resource,
+ :attach_fields
+
+ def initialize(id, **args, &block)
+ super(id, **args, &block)
+
+ @attach_scope = args[:attach_scope]
+ # Defaults to nil so that if not set falls back to `link_to_child_resource` defined in the resource
+ @link_to_child_resource = args[:link_to_child_resource]
+ @attach_fields = args[:attach_fields]
+ end
+ end
+ end
+end
diff --git a/lib/avo/fields/has_many_field.rb b/lib/avo/fields/has_many_field.rb
index 6c7932f164..d68f579f5b 100644
--- a/lib/avo/fields/has_many_field.rb
+++ b/lib/avo/fields/has_many_field.rb
@@ -1,14 +1,6 @@
module Avo
module Fields
- class HasManyField < HasBaseField
- def initialize(id, **args, &block)
- args[:updatable] = false
-
- only_on Avo.configuration.resource_default_view
-
- super(id, **args, &block)
- end
-
+ class HasManyField < HasManyBaseField
def translated_name(default:)
t(translation_key, count: 2, default: default_name).capitalize
end
diff --git a/lib/avo/fields/has_one_field.rb b/lib/avo/fields/has_one_field.rb
index 5b80072b1a..cbec708ad5 100644
--- a/lib/avo/fields/has_one_field.rb
+++ b/lib/avo/fields/has_one_field.rb
@@ -1,7 +1,8 @@
module Avo
module Fields
- class HasOneField < HasBaseField
- attr_accessor :relation_method
+ class HasOneField < FrameBaseField
+ attr_reader :attach_fields,
+ :attach_scope
def initialize(id, **args, &block)
hide_on :forms
@@ -9,8 +10,8 @@ def initialize(id, **args, &block)
super(id, **args, &block)
@placeholder ||= I18n.t "avo.choose_an_option"
-
- @relation_method = name.to_s.parameterize.underscore
+ @attach_fields = args[:attach_fields]
+ @attach_scope = args[:attach_scope]
end
def label
@@ -37,6 +38,8 @@ def fill_field(record, key, value, params)
record
end
+
+ def is_searchable? = false
end
end
end
diff --git a/lib/avo/fields/many_frame_base_field.rb b/lib/avo/fields/many_frame_base_field.rb
new file mode 100644
index 0000000000..7746637f5c
--- /dev/null
+++ b/lib/avo/fields/many_frame_base_field.rb
@@ -0,0 +1,24 @@
+module Avo
+ module Fields
+ class ManyFrameBaseField < FrameBaseField
+ include Avo::Fields::Concerns::IsSearchable
+
+ attr_reader :scope,
+ :hide_search_input,
+ :discreet_pagination
+
+ def initialize(id, **args, &block)
+ args[:updatable] = false
+
+ only_on Avo.configuration.resource_default_view
+
+ super(id, **args, &block)
+
+ @searchable = args[:searchable]
+ @scope = args[:scope]
+ @hide_search_input = args[:hide_search_input]
+ @discreet_pagination = args[:discreet_pagination]
+ end
+ end
+ end
+end
diff --git a/lib/avo/resources/array_resource.rb b/lib/avo/resources/array_resource.rb
new file mode 100644
index 0000000000..a382091184
--- /dev/null
+++ b/lib/avo/resources/array_resource.rb
@@ -0,0 +1,97 @@
+module Avo
+ module Resources
+ class ArrayResource < Base
+ extend ActiveSupport::DescendantsTracker
+
+ include Avo::Concerns::FindAssociationField
+
+ delegate :model_class, to: :class
+
+ class_attribute :pagination, default: {
+ type: :array
+ }
+
+ class << self
+ def model_class
+ @@model_class ||= ActiveSupport::OrderedOptions.new.tap do |obj|
+ obj.model_name = ActiveSupport::OrderedOptions.new.tap do |thing|
+ thing.plural = route_key
+ end
+ end
+ end
+ end
+
+ def records = []
+
+ def find_record(id, query: nil, params: nil)
+ fetched_records = fetch_records
+
+ return super(id, query: fetched_records, params:) if is_active_record_relation?(fetched_records)
+
+ fetched_records.find { |i| i.id.to_s == id.to_s }
+ end
+
+ def fetch_records(array_of_records = nil)
+ array_of_records ||= records
+ raise "Unable to fetch any #{name}" if array_of_records.nil?
+
+ # When the array of records is declared in a field's block, we need to get that block from the parent resource
+ # If there is no block try to pick those from the parent_record
+ # Fallback to resource's def records method
+ if params[:via_resource_class].present?
+ via_resource = Avo.resource_manager.get_resource(params[:via_resource_class])
+ via_record = via_resource.find_record params[:via_record_id], params: params
+ via_resource = via_resource.new record: via_record, view: :show
+ via_resource.detect_fields
+
+ association_field = find_association_field(resource: via_resource, association: route_key)
+
+ records_from_field_or_record = Avo::ExecutionContext.new(target: association_field.block).handle || via_record.try(route_key)
+
+ array_of_records = records_from_field_or_record || array_of_records
+ end
+
+ @fetched_records ||= if is_array_of_active_records?(array_of_records)
+ @@model_class = array_of_records.first.class
+ @@model_class.where(id: array_of_records.map(&:id))
+ elsif is_active_record_relation?(array_of_records)
+ @@model_class = array_of_records.try(:model)
+ array_of_records
+ else
+ # Dynamically create a class with accessors for all unique keys from the records
+ keys = array_of_records.flat_map(&:keys).uniq
+
+ custom_class = Class.new do
+ include ActiveModel::Model
+
+ # Dynamically define accessors
+ attr_accessor(*keys)
+
+ define_method(:to_param) do
+ id
+ end
+ end
+
+ # Map the records to instances of the dynamically created class
+ array_of_records.map do |item|
+ custom_class.new(item)
+ end
+ end
+ end
+
+ def is_array_of_active_records?(array_of_records = records)
+ @is_array_of_active_records ||= array_of_records.all? { |element| element.is_a?(ActiveRecord::Base) }
+ end
+
+ def is_active_record_relation?(array_of_records = records)
+ @is_active_record_relation ||= array_of_records.is_a?(ActiveRecord::Relation)
+ end
+
+ def resource_type_array? = true
+
+ def sort_by_param = nil
+
+ def sorting_supported? = false
+ end
+ end
+end
diff --git a/lib/avo/resources/base.rb b/lib/avo/resources/base.rb
index 4b3b342e4e..2ccae9c766 100644
--- a/lib/avo/resources/base.rb
+++ b/lib/avo/resources/base.rb
@@ -653,6 +653,20 @@ def get_external_link
Avo::ExecutionContext.new(target: external_link, resource: self, record: record).handle
end
+ def resource_type_array? = false
+
+ def sort_by_param
+ available_columns = model_class.column_names
+
+ if available_columns.include?(default_sort_column.to_s)
+ default_sort_column
+ elsif available_columns.include?("created_at")
+ :created_at
+ end
+ end
+
+ def sorting_supported? = true
+
private
def flatten_keys(array)
diff --git a/lib/avo/resources/resource_manager.rb b/lib/avo/resources/resource_manager.rb
index 8eada1bd12..98825dc605 100644
--- a/lib/avo/resources/resource_manager.rb
+++ b/lib/avo/resources/resource_manager.rb
@@ -40,7 +40,8 @@ def fetch_resources
load_resources_namespace
end
- BaseResource.descendants
+ # All descendants from Avo::Resources::Base except the internal ones
+ Base.descendants - [Avo::BaseResource, Avo::Resources::ArrayResource]
end
def load_resources_namespace
diff --git a/lib/generators/avo/concerns/parent_controller.rb b/lib/generators/avo/concerns/parent_controller.rb
index 4ef115b35d..80d392b0d2 100644
--- a/lib/generators/avo/concerns/parent_controller.rb
+++ b/lib/generators/avo/concerns/parent_controller.rb
@@ -12,6 +12,8 @@ module ParentController
end
def parent_controller
+ return "Avo::ArrayController" if options["array"]
+
options["parent-controller"] || ::Avo.configuration.resource_parent_controller
end
end
diff --git a/lib/generators/avo/resource_generator.rb b/lib/generators/avo/resource_generator.rb
index d67559bbf5..e5797f3e28 100644
--- a/lib/generators/avo/resource_generator.rb
+++ b/lib/generators/avo/resource_generator.rb
@@ -17,6 +17,11 @@ class ResourceGenerator < NamedBaseGenerator
type: :string,
required: false
+ class_option "array",
+ desc: "Indicates if the resource should be an array.",
+ type: :boolean,
+ default: false
+
def create
return if override_controller?
@@ -25,6 +30,14 @@ def create
end
no_tasks do
+ def parent_resource
+ if options["array"]
+ "Avo::Resources::ArrayResource"
+ else
+ "Avo::BaseResource"
+ end
+ end
+
def can_connect_to_the_database?
result = false
begin
diff --git a/lib/generators/avo/templates/resource/resource.tt b/lib/generators/avo/templates/resource/resource.tt
index a583f44436..7982da1668 100644
--- a/lib/generators/avo/templates/resource/resource.tt
+++ b/lib/generators/avo/templates/resource/resource.tt
@@ -1,11 +1,30 @@
-class Avo::Resources::<%= resource_class %> < Avo::BaseResource
+class Avo::Resources::<%= resource_class %> < <%= parent_resource %><% if options["array"] %>
+ def records
+ [
+ {
+ id: 1,
+ title: "Example 1"
+ },
+ {
+ id: 2,
+ title: "Example 2"
+ }
+ ]
+ end
+ <% else %>
# self.includes = []
# self.attachments = []<%= model_class_from_args %>
# self.search = {
# query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) }
# }
-
+ <% end %><% if options["array"] %>
+ def fields
+ field :id, as: :id
+ field :title
+ end<% else %>
def fields
field :id, as: :id<%= generate_fields %>
- end
+ end<% end %>
end
+
+
diff --git a/spec/dummy/app/avo/resources/attendee.rb b/spec/dummy/app/avo/resources/attendee.rb
new file mode 100644
index 0000000000..9a871758ff
--- /dev/null
+++ b/spec/dummy/app/avo/resources/attendee.rb
@@ -0,0 +1,21 @@
+class Avo::Resources::Attendee < Avo::Resources::ArrayResource
+ self.description = -> {
+ return "Attendees from field block" if params["resource_name"] == "events"
+ return "First 6 users" if params["resource_name"] == "courses"
+
+ "#{@record&.name || "All the users"} rendered as array resource"
+ }
+
+ # Test array resource, this method should be called only on attendees index
+ def records = User.all
+
+ def fields
+ field :id, as: :id
+ field :name
+
+ with_options visible: -> { !resource.record.is_a?(User) } do
+ field :role
+ field :organization
+ end
+ end
+end
diff --git a/spec/dummy/app/avo/resources/course.rb b/spec/dummy/app/avo/resources/course.rb
index 92c0580772..ee1f146484 100644
--- a/spec/dummy/app/avo/resources/course.rb
+++ b/spec/dummy/app/avo/resources/course.rb
@@ -11,6 +11,8 @@ def show_fields
fields_bag
field :links, as: :has_many, searchable: true, placeholder: "Click to choose a link",
discreet_pagination: true
+
+ field :attendees, as: :array
end
def index_fields
diff --git a/spec/dummy/app/avo/resources/event.rb b/spec/dummy/app/avo/resources/event.rb
index 0d2301a91f..2923e330eb 100644
--- a/spec/dummy/app/avo/resources/event.rb
+++ b/spec/dummy/app/avo/resources/event.rb
@@ -39,5 +39,30 @@ def fields
# Example for error message when resource is missing
field :location, as: :belongs_to
end
+
+ field :attendees, as: :array do
+ [
+ {id: 1, name: "John Doe", role: "Software Developer", organization: "TechCorp"},
+ {id: 2, name: "Jane Smith", role: "Data Scientist", organization: "DataPros"},
+ {id: 3, name: "Emily Davis", role: "Product Manager", organization: "Startup Inc."},
+ {id: 4, name: "Kevin Roberts", role: "CTO", organization: "FutureTech"},
+ {id: 5, name: "Sarah Johnson", role: "UI/UX Designer", organization: "DesignWorks"},
+ {id: 6, name: "Michael Lee", role: "Backend Engineer", organization: "CodeBase"},
+ {id: 7, name: "Olivia Brown", role: "Project Coordinator", organization: "BuildIt"},
+ {id: 8, name: "Ethan Williams", role: "AI Specialist", organization: "InnoBots"},
+ {id: 9, name: "Sophia Martinez", role: "Marketing Strategist", organization: "Brandify"},
+ {id: 10, name: "Jacob Wilson", role: "DevOps Engineer", organization: "OpsWorld"},
+ {id: 11, name: "Ava Taylor", role: "Business Analyst", organization: "AnalyzeNow"},
+ {id: 12, name: "William Hernandez", role: "Full Stack Developer", organization: "Webify"},
+ {id: 13, name: "Mia Moore", role: "HR Manager", organization: "PeopleFirst"},
+ {id: 14, name: "James Anderson", role: "Blockchain Developer", organization: "ChainWorks"},
+ {id: 15, name: "Charlotte White", role: "Product Designer", organization: "Cre8tive"},
+ {id: 16, name: "Benjamin Green", role: "Cybersecurity Analyst", organization: "SecureNet"},
+ {id: 17, name: "Amelia Clark", role: "Data Engineer", organization: "BigData Solutions"},
+ {id: 18, name: "Lucas Carter", role: "Scrum Master", organization: "AgileHub"},
+ {id: 19, name: "Ella Thompson", role: "Software Architect", organization: "CodeVision"},
+ {id: 20, name: "Alexander Scott", role: "Solutions Consultant", organization: "Innovate Consulting"}
+ ]
+ end
end
end
diff --git a/spec/dummy/app/avo/resources/movie.rb b/spec/dummy/app/avo/resources/movie.rb
new file mode 100644
index 0000000000..eb3af5a7d0
--- /dev/null
+++ b/spec/dummy/app/avo/resources/movie.rb
@@ -0,0 +1,284 @@
+class Avo::Resources::Movie < Avo::Resources::ArrayResource
+ # self.includes = []
+ # self.attachments = []
+ # self.search = {
+ # query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) }
+ # }
+
+ def records
+ [
+ {
+ id: 1,
+ name: "The Shawshank Redemption",
+ release_date: "1994-09-23"
+ },
+ {
+ id: 2,
+ name: "The Godfather",
+ release_date: "1972-03-24",
+ fun_fact: "The iconic cat in the opening scene was a stray found by director Francis Ford Coppola on the studio lot."
+ },
+ {
+ id: 3,
+ name: "Pulp Fiction",
+ release_date: "1994-10-14"
+ },
+ {
+ id: 4,
+ name: "The Dark Knight",
+ release_date: "2008-07-18"
+ },
+ {
+ id: 5,
+ name: "Fight Club",
+ release_date: "1999-10-15"
+ },
+ {
+ id: 6,
+ name: "Inception",
+ release_date: "2010-07-16"
+ },
+ {
+ id: 7,
+ name: "The Matrix",
+ release_date: "1999-03-31",
+ fun_fact: "The actors trained in martial arts for months before shooting to perform many of their own stunts."
+ },
+ {
+ id: 8,
+ name: "Goodfellas",
+ release_date: "1990-09-19"
+ },
+ {
+ id: 9,
+ name: "The Silence of the Lambs",
+ release_date: "1991-02-14"
+ },
+ {
+ id: 10,
+ name: "Interstellar",
+ release_date: "2014-11-07"
+ },
+ {
+ id: 11,
+ name: "The Lion King",
+ release_date: "1994-06-24"
+ },
+ {
+ id: 12,
+ name: "Forrest Gump",
+ release_date: "1994-07-06"
+ },
+ {
+ id: 13,
+ name: "Jurassic Park",
+ release_date: "1993-06-11"
+ },
+ {
+ id: 14,
+ name: "Titanic",
+ release_date: "1997-12-19"
+ },
+ {
+ id: 15,
+ name: "Avatar",
+ release_date: "2009-12-18"
+ },
+ {
+ id: 16,
+ name: "The Avengers",
+ release_date: "2012-05-04"
+ },
+ {
+ id: 17,
+ name: "Star Wars: Episode IV - A New Hope",
+ release_date: "1977-05-25"
+ },
+ {
+ id: 18,
+ name: "Back to the Future",
+ release_date: "1985-07-03"
+ },
+ {
+ id: 19,
+ name: "Gladiator",
+ release_date: "2000-05-05"
+ },
+ {
+ id: 20,
+ name: "The Lord of the Rings: The Fellowship of the Ring",
+ release_date: "2001-12-19"
+ },
+ {
+ id: 21,
+ name: "The Departed",
+ release_date: "2006-10-06"
+ },
+ {
+ id: 22,
+ name: "Schindler\"s List",
+ release_date: "1993-12-15"
+ },
+ {
+ id: 23,
+ name: "The Green Mile",
+ release_date: "1999-12-10"
+ },
+ {
+ id: 24,
+ name: "Saving Private Ryan",
+ release_date: "1998-07-24"
+ },
+ {
+ id: 25,
+ name: "The Social Network",
+ release_date: "2010-10-01"
+ },
+ {
+ id: 26,
+ name: "The Prestige",
+ release_date: "2006-10-20"
+ },
+ {
+ id: 27,
+ name: "The Grand Budapest Hotel",
+ release_date: "2014-03-07"
+ },
+ {
+ id: 28,
+ name: "La La Land",
+ release_date: "2016-12-09",
+ fun_fact: "Ryan Gosling learned to play the piano for his role, mastering several songs within three months."
+ },
+ {
+ id: 29,
+ name: "Get Out",
+ release_date: "2017-02-24"
+ },
+ {
+ id: 30,
+ name: "Parasite",
+ release_date: "2019-10-11",
+ fun_fact: "It became the first non-English language film to win the Academy Award for Best Picture."
+ },
+ {
+ id: 31,
+ name: "Whiplash",
+ release_date: "2014-10-10"
+ },
+ {
+ id: 32,
+ name: "Mad Max: Fury Road",
+ release_date: "2015-05-15"
+ },
+ {
+ id: 33,
+ name: "The Shape of Water",
+ release_date: "2017-12-01"
+ },
+ {
+ id: 34,
+ name: "Black Panther",
+ release_date: "2018-02-16",
+ fun_fact: "The Wakandan language is based on the real South African language, Xhosa."
+ },
+ {
+ id: 35,
+ name: "Moonlight",
+ release_date: "2016-10-21"
+ },
+ {
+ id: 36,
+ name: "A Beautiful Mind",
+ release_date: "2001-12-21"
+ },
+ {
+ id: 37,
+ name: "The Wolf of Wall Street",
+ release_date: "2013-12-25"
+ },
+ {
+ id: 38,
+ name: "No Country for Old Men",
+ release_date: "2007-11-09"
+ },
+ {
+ id: 39,
+ name: "There Will Be Blood",
+ release_date: "2007-12-26"
+ },
+ {
+ id: 40,
+ name: "The Revenant",
+ release_date: "2015-12-25"
+ },
+ {
+ id: 41,
+ name: "Django Unchained",
+ release_date: "2012-12-25"
+ },
+ {
+ id: 42,
+ name: "Inglourious Basterds",
+ release_date: "2009-08-21"
+ },
+ {
+ id: 43,
+ name: "The Pianist",
+ release_date: "2002-09-24"
+ },
+ {
+ id: 44,
+ name: "A Clockwork Orange",
+ release_date: "1971-12-19"
+ },
+ {
+ id: 45,
+ name: "The Big Lebowski",
+ release_date: "1998-03-06"
+ },
+ {
+ id: 46,
+ name: "Eternal Sunshine of the Spotless Mind",
+ release_date: "2004-03-19"
+ },
+ {
+ id: 47,
+ name: "The Sixth Sense",
+ release_date: "1999-08-06"
+ },
+ {
+ id: 48,
+ name: "Memento",
+ release_date: "2000-09-05"
+ },
+ {
+ id: 49,
+ name: "American Beauty",
+ release_date: "1999-09-15"
+ },
+ {
+ id: 50,
+ name: "Good Will Hunting",
+ release_date: "1997-12-05"
+ }
+ ]
+ end
+
+ def fields
+ main_panel do
+ field :id, as: :id
+ field :name, as: :text
+ field :release_date, as: :date
+ field :fun_fact, only_on: :index, visible: -> { resource.record.fun_fact.present? } do
+ record.fun_fact.truncate_words(10)
+ end
+
+ sidebar do
+ field :fun_fact do
+ record.fun_fact || "There is no register of a fun fact for #{record.name}"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/dummy/app/controllers/avo/attendees_controller.rb b/spec/dummy/app/controllers/avo/attendees_controller.rb
new file mode 100644
index 0000000000..6c8b7ff23d
--- /dev/null
+++ b/spec/dummy/app/controllers/avo/attendees_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::AttendeesController < Avo::ArrayController
+end
diff --git a/spec/dummy/app/controllers/avo/movies_controller.rb b/spec/dummy/app/controllers/avo/movies_controller.rb
new file mode 100644
index 0000000000..4e485e3bed
--- /dev/null
+++ b/spec/dummy/app/controllers/avo/movies_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::MoviesController < Avo::ArrayController
+end
diff --git a/spec/dummy/app/models/course.rb b/spec/dummy/app/models/course.rb
index fb1a6bf687..92cc01f6d8 100644
--- a/spec/dummy/app/models/course.rb
+++ b/spec/dummy/app/models/course.rb
@@ -72,4 +72,11 @@ def self.ransackable_attributes(auth_object = nil)
def self.ransackable_associations(auth_object = nil)
["links"]
end
+
+ # Testing purposes on Avo::Resources::Course
+ # Tests that the field is populated from here
+ # field :attendees, as: :array
+ def attendees
+ User.all.first(6)
+ end
end
diff --git a/spec/dummy/app/models/event.rb b/spec/dummy/app/models/event.rb
index 9b9ec60bd2..0841e5e929 100644
--- a/spec/dummy/app/models/event.rb
+++ b/spec/dummy/app/models/event.rb
@@ -21,4 +21,8 @@ class Event < ApplicationRecord
def first_user
User.first
end
+
+ def attendees
+ raise "Test array resource, this method should not be called"
+ end
end
diff --git a/spec/features/avo/array_resource_spec.rb b/spec/features/avo/array_resource_spec.rb
new file mode 100644
index 0000000000..de128c9856
--- /dev/null
+++ b/spec/features/avo/array_resource_spec.rb
@@ -0,0 +1,39 @@
+require "rails_helper"
+
+RSpec.feature "ArrayResource", type: :feature do
+ describe "from index to show" do
+ it "render the movies index using the def records resource method and navigate to show" do
+ visit path = avo.resources_movies_path(per_page: 12)
+
+ expect(find("table thead").text).to eq "Select all\nId\nName\nRelease date\nFun fact"
+ expect(page).to have_text "The Shawshank Redemption"
+ expect(page).to have_text "The iconic cat in the opening scene was a stray..."
+
+ first("a[href=\"#{path}&page=2\"]").click
+ expect(find("table thead").text).to eq "Select all\nId\nName\nRelease date"
+
+ expect(page).to have_text "The Lord of the Rings: The Fellowship of the Ring"
+
+ first("a[href=\"#{path}&page=3\"]").click
+
+ first('a[href="/admin/resources/movies/28"]').click
+
+ within("div.resource-sidebar-component") do
+ fun_fact_text = find('div[data-field-id="fun_fact"] [data-slot="value"]').text
+ expect(fun_fact_text).to eq "Ryan Gosling learned to play the piano for his role, mastering several songs within three months."
+ end
+ end
+
+ it "render the attendees index using the def records resource method and navigate to show" do
+ visit avo.resources_attendees_path
+
+ expect(find("table thead").text).to eq "Select all\nId\nName"
+ expect(page).to have_text User.first.name
+
+ first("a[href=\"#{avo.resources_attendee_path User.first}\"]").click
+
+ name = find('div[data-field-id="name"] [data-slot="value"]').text
+ expect(name).to eq User.first.name
+ end
+ end
+end
diff --git a/spec/features/avo/model_missing_resource_spec.rb b/spec/features/avo/model_missing_resource_spec.rb
index aea85dfcb4..c7f818dbb1 100644
--- a/spec/features/avo/model_missing_resource_spec.rb
+++ b/spec/features/avo/model_missing_resource_spec.rb
@@ -4,7 +4,7 @@
subject { visit url }
context "when has_one field" do
- let(:url) { "/admin/resources/stores/#{store.id}/location/#{location.id}?turbo_frame=has_one_field_show_location" }
+ let(:url) { "/admin/resources/stores/#{store.id}/location/#{location.id}?view=show&turbo_frame=has_one_field_show_location&show_location_field=1" }
let!(:store) { create :store }
let!(:location) { create :location, store: store }
@@ -28,7 +28,7 @@
context "when has_many field" do
let!(:team) { create :team }
- let(:url) { "/admin/resources/teams/#{team.id}/locations?turbo_frame=has_many_field_show_locations" }
+ let(:url) { "/admin/resources/teams/#{team.id}/locations?turbo_frame=has_many_field_show_locations&show_location_field=1" }
it "shows informative error with suggested solution for missing resource" do
expect {
@@ -39,7 +39,7 @@
context "when has_and_belongs_to_many field" do
let!(:course) { create :course }
- let(:url) { "/admin/resources/courses/#{course.id}/locations?turbo_frame=has_and_belongs_to_many_field_show_locations" }
+ let(:url) { "/admin/resources/courses/#{course.id}/locations?turbo_frame=has_and_belongs_to_many_field_show_locations&show_location_field=1" }
it "shows informative error with suggested solution for missing resource" do
expect {
diff --git a/spec/system/avo/array_resource_spec.rb b/spec/system/avo/array_resource_spec.rb
new file mode 100644
index 0000000000..0b259a0b73
--- /dev/null
+++ b/spec/system/avo/array_resource_spec.rb
@@ -0,0 +1,54 @@
+require "rails_helper"
+
+RSpec.feature "ArrayResource", type: :system do
+ describe "from has_many association to show" do
+ let!(:event) { create :event }
+ let!(:course) { create :course }
+ let(:users) { create_list :user, 6 }
+
+ it "using the field block" do
+ visit avo.resources_event_path(event)
+
+ wait_for_loaded
+
+ expect(find("table thead").text).to eq "Select all\n\t\nID\n\t\nNAME\n\t\nROLE\n\t\nORGANIZATION"
+
+ expect(page).to have_text("Attendees from field")
+ expect(page).to have_text("John Doe")
+ expect(page).to have_text("Ethan Williams")
+
+ within("nav.pagy.nav") do
+ click_link("2")
+ end
+
+ expect(page).to have_text("Benjamin Green")
+ expect(page).to have_text("Sophia Martinez")
+
+ first('a[data-control="show"]').click
+ expect(page).to have_text("Sophia Martinez rendered as array resource")
+
+ expect(page).to have_text("Sophia Martinez")
+ expect(page).to have_text("Marketing Strategist")
+ expect(page).to have_text("Brandify")
+ end
+
+ it "using the record method" do
+ visit avo.resources_course_path(course)
+
+ scroll_to find('turbo-frame[id="array_field_show_attendees"]')
+
+ wait_for_turbo_frame_id("array_field_show_attendees")
+
+ expect(page).to have_text("First 6 users")
+
+ expect(find("table thead").text).to eq "Select all\n\t\nID\n\t\nNAME"
+
+ User.first(6).each do |user|
+ expect(page).to have_text(user.name)
+ end
+
+ all('a[data-control="show"]').last.click
+ expect(page).to have_text("#{User.first(6).last.name} rendered as array resource")
+ end
+ end
+end
diff --git a/spec/system/avo/date_time_fields/date_time_spec.rb b/spec/system/avo/date_time_fields/date_time_spec.rb
index 1dfa2df339..67a5585448 100644
--- a/spec/system/avo/date_time_fields/date_time_spec.rb
+++ b/spec/system/avo/date_time_fields/date_time_spec.rb
@@ -177,6 +177,7 @@
expect(text_input.value).to eq "2000-01-01 06:00:00"
save
+ wait_for_loaded
expect(show_field_value(id: :started_at)).to eq "January 01, 2000 06:00:00 UTC"
end
diff --git a/spec/system/avo/date_time_fields/time_spec.rb b/spec/system/avo/date_time_fields/time_spec.rb
index 486468bf99..9f2d83d261 100644
--- a/spec/system/avo/date_time_fields/time_spec.rb
+++ b/spec/system/avo/date_time_fields/time_spec.rb
@@ -42,6 +42,7 @@
expect(text_input.value).to eq "16:30"
save
+ wait_for_loaded
expect(find_field_value_element("starting_at").text).to eq "16:30"
end
@@ -105,6 +106,7 @@
expect(text_input.value).to eq "16:30"
save
+ wait_for_loaded
expect(find_field_value_element("starting_at").text).to eq "16:30"
end
@@ -226,6 +228,7 @@
close_picker
save
+ wait_for_loaded
expect(find_field_value_element("starting_at").text).to eq "09:30"
end
diff --git a/spec/system/avo/date_time_fields/timezone_spec.rb b/spec/system/avo/date_time_fields/timezone_spec.rb
index 40e84f6d51..94db3d96d6 100644
--- a/spec/system/avo/date_time_fields/timezone_spec.rb
+++ b/spec/system/avo/date_time_fields/timezone_spec.rb
@@ -61,6 +61,7 @@
close_picker
save
+ wait_for_loaded
expect(show_field_value(id: :started_at)).to eq "March 25, 2024 09:24:17 CET"
end
@@ -101,6 +102,7 @@
expect(text_input.value).to eq "2024-03-25 08:23:00"
save
+ wait_for_loaded
expect(show_field_value(id: :started_at)).to eq "March 25, 2024 08:23:00 UTC"
end
@@ -119,6 +121,7 @@
close_picker
save
+ wait_for_loaded
expect(show_field_value(id: :started_at)).to eq "March 25, 2024 08:24:17 UTC"
end