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