diff --git a/.erb-lint_rubocop.yml b/.erb-lint_rubocop.yml index 9eac111f3e..93da3f695b 100644 --- a/.erb-lint_rubocop.yml +++ b/.erb-lint_rubocop.yml @@ -9,3 +9,6 @@ Lint/UselessAssignment: Layout/FirstArgumentIndentation: Enabled: false + +Layout/ArgumentAlignment: + Enabled: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..06d8630b5e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: Priority Support + url: https://avohq.io/support + about: Get fast support from the authors of Avo diff --git a/app/components/avo/cover_photo_component.html.erb b/app/components/avo/cover_photo_component.html.erb new file mode 100644 index 0000000000..0ef26c0909 --- /dev/null +++ b/app/components/avo/cover_photo_component.html.erb @@ -0,0 +1,3 @@ + diff --git a/app/components/avo/cover_photo_component.rb b/app/components/avo/cover_photo_component.rb new file mode 100644 index 0000000000..e79d869100 --- /dev/null +++ b/app/components/avo/cover_photo_component.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Avo::CoverPhotoComponent < ViewComponent::Base + def initialize(cover_photo:) + @cover_photo = cover_photo + @size = cover_photo&.size + end + + # aspect-cover-sm + # aspect-cover-md + # aspect-cover-lg + def size_class + "aspect-cover-#{@size}" + end + + def render? + @cover_photo.present? && @cover_photo.visible_in_current_view? + end +end diff --git a/app/components/avo/items/panel_component.rb b/app/components/avo/items/panel_component.rb index 0541217506..cdd1a7baa8 100644 --- a/app/components/avo/items/panel_component.rb +++ b/app/components/avo/items/panel_component.rb @@ -34,7 +34,9 @@ def args description: @resource.description, display_breadcrumbs: display_breadcrumbs, index: 0, - data: {panel_id: "main"} + data: {panel_id: "main"}, + cover_photo: @resource.cover_photo, + profile_photo: @resource.profile_photo } else {name: @item.name, description: @item.description, index: @index} diff --git a/app/components/avo/panel_component.html.erb b/app/components/avo/panel_component.html.erb index 362af3c80e..3f9b2f1dee 100644 --- a/app/components/avo/panel_component.html.erb +++ b/app/components/avo/panel_component.html.erb @@ -1,29 +1,36 @@ <%= content_tag :div, data: data_attributes, class: classes do %> + <%= render Avo::CoverPhotoComponent.new cover_photo: @cover_photo %> + <% if render_header? %> -
- <% if display_breadcrumbs? %> - - <% end %> -
-
- <% if name_slot? %> - <%= name_slot %> - <% else %> - <%= render Avo::PanelNameComponent.new name: @name %> - <% end %> - <% if description.present? %> -
- <%== description %> +
+
+ <%= render Avo::ProfilePhotoComponent.new profile_photo: @profile_photo %> +
+ <% if display_breadcrumbs? %> + <% end %> -
- <% if tools.present? %> -
- <%= tools %> +
+
+ <% if name_slot? %> + <%= name_slot %> + <% else %> + <%= render Avo::PanelNameComponent.new name: @name %> + <% end %> + <% if description.present? %> +
+ <%== description %> +
+ <% end %> +
+ <% if tools.present? %> +
+ <%= tools %> +
+ <% end %>
- <% end %> +
<% end %> diff --git a/app/components/avo/panel_component.rb b/app/components/avo/panel_component.rb index 527771099c..bd8cd38fa6 100644 --- a/app/components/avo/panel_component.rb +++ b/app/components/avo/panel_component.rb @@ -5,10 +5,10 @@ class Avo::PanelComponent < Avo::BaseComponent attr_reader :title # deprecating title in favor of name attr_reader :name - attr_reader :classes delegate :white_panel_classes, to: :helpers + renders_one :cover_slot renders_one :name_slot renders_one :tools renders_one :body @@ -18,7 +18,7 @@ class Avo::PanelComponent < Avo::BaseComponent renders_one :footer_tools renders_one :footer - def initialize(name: nil, description: nil, body_classes: nil, data: {}, display_breadcrumbs: false, index: nil, classes: nil, **args) + def initialize(name: nil, description: nil, body_classes: nil, data: {}, display_breadcrumbs: false, index: nil, classes: nil, profile_photo: nil, cover_photo: nil, **args) # deprecating title in favor of name @title = args[:title] @name = name || title @@ -28,6 +28,12 @@ def initialize(name: nil, description: nil, body_classes: nil, data: {}, display @data = data @display_breadcrumbs = display_breadcrumbs @index = index + @profile_photo = profile_photo + @cover_photo = cover_photo + end + + def classes + class_names(@classes, "has-cover-photo": @cover_photo.present?, "has-profile-photo": @profile_photo.present?) end private diff --git a/app/components/avo/panel_name_component.html.erb b/app/components/avo/panel_name_component.html.erb index 41f79265ef..a245215de4 100644 --- a/app/components/avo/panel_name_component.html.erb +++ b/app/components/avo/panel_name_component.html.erb @@ -1,4 +1,4 @@
- <%= link_to_if @url.present?, @name, @url, target: @target, class: class_names("text-gray-800", @classes) %> + <%= link_to_if @url.present?, @name, @url, target: @target, class: class_names("text-gray-800", @classes) %> <%= body %>
diff --git a/app/components/avo/profile_photo_component.html.erb b/app/components/avo/profile_photo_component.html.erb new file mode 100644 index 0000000000..48ed6ef416 --- /dev/null +++ b/app/components/avo/profile_photo_component.html.erb @@ -0,0 +1,6 @@ +<%= image_tag helpers.main_app.url_for(@profile_photo.value), class: class_names( + "relative rounded-full object-cover aspect-square border-4 border-application", + "has-cover-photo:sm:ml-8 sm:mr-2", + "self-center sm:self-start", + "size-24 has-cover-photo:size-36 has-cover-photo:sm:size-48 has-cover-photo:-mt-12", +), data: {component: self.class.to_s.underscore} %> diff --git a/app/components/avo/profile_photo_component.rb b/app/components/avo/profile_photo_component.rb new file mode 100644 index 0000000000..c9baf62777 --- /dev/null +++ b/app/components/avo/profile_photo_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Avo::ProfilePhotoComponent < ViewComponent::Base + def initialize(profile_photo:) + @profile_photo = profile_photo + end + + def render? + @profile_photo.present? && @profile_photo.visible_in_current_view? + end +end diff --git a/app/components/avo/views/resource_index_component.html.erb b/app/components/avo/views/resource_index_component.html.erb index 98efae362b..cea1dc5642 100644 --- a/app/components/avo/views/resource_index_component.html.erb +++ b/app/components/avo/views/resource_index_component.html.erb @@ -7,9 +7,14 @@ **@resource.stimulus_data_attributes } do %> <%= render_cards_component %> - <%= render Avo::PanelComponent.new(description: description, data: { component: 'resources-index' }, display_breadcrumbs: @reflection.blank?) do |c| %> + <%= render Avo::PanelComponent.new( + description: description, + cover_photo: resource.cover_photo, + data: {component: "resources-index"}, + display_breadcrumbs: @reflection.blank? + ) 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| %> + <%= 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| %> <% panel_name_component.with_body do %> <% if reloadable %> <%= button_tag data: { controller: "panel-refresh", action: "click->panel-refresh#refresh" } do %> @@ -28,10 +33,10 @@
<%= render scopes_list if can_render_scopes? %>
-
+
">
<% if show_search_input %> - <%= render partial: 'avo/partials/resource_search', locals: {resource: @resource.route_key, via_reflection: via_reflection} %> + <%= render partial: "avo/partials/resource_search", locals: {resource: @resource.route_key, via_reflection: via_reflection} %> <% else %> <%# Offset for the space-y-2 property when the search is missing %>
@@ -43,7 +48,7 @@ <%= render Avo::FiltersComponent.new filters: @filters, resource: @resource, applied_filters: @applied_filters, parent_record: parent_record %> - <%= render partial: 'avo/partials/view_toggle_button', locals: { available_view_types: available_view_types, view_type: view_type, turbo_frame: turbo_frame } %> + <%= render partial: "avo/partials/view_toggle_button", locals: { available_view_types: available_view_types, view_type: view_type, turbo_frame: turbo_frame } %>
<% if has_dynamic_filters? %> @@ -74,7 +79,7 @@ <% if view_type.to_sym == :table || view_type.to_sym == :map %> <% if @records.present? %>
- <%= render Avo::PaginatorComponent.new pagy: @pagy, turbo_frame: turbo_frame || 'none', index_params: @index_params, resource: @resource, parent_record: parent_record, discreet_pagination: field&.discreet_pagination %> + <%= render Avo::PaginatorComponent.new pagy: @pagy, turbo_frame: turbo_frame || "none", index_params: @index_params, resource: @resource, parent_record: parent_record, discreet_pagination: field&.discreet_pagination %>
<% end %> <% end %> @@ -83,7 +88,7 @@ <%= render Avo::Index::ResourceGridComponent.new(resources: @resources, resource: @resource, reflection: @reflection, parent_record: parent_record, parent_resource: parent_resource, actions: actions) %>
- <%= render Avo::PaginatorComponent.new pagy: @pagy, turbo_frame: turbo_frame || 'none', index_params: @index_params, resource: @resource, parent_record: parent_record, discreet_pagination: field&.discreet_pagination %> + <%= render Avo::PaginatorComponent.new pagy: @pagy, turbo_frame: turbo_frame || "none", index_params: @index_params, resource: @resource, parent_record: parent_record, discreet_pagination: field&.discreet_pagination %>
<% end %> <% end %> diff --git a/lib/avo/base_resource.rb b/lib/avo/base_resource.rb index 6c8591182d..47dbf23f64 100644 --- a/lib/avo/base_resource.rb +++ b/lib/avo/base_resource.rb @@ -9,6 +9,8 @@ class BaseResource include Avo::Concerns::HasResourceStimulusControllers include Avo::Concerns::ModelClassConstantized include Avo::Concerns::HasDescription + include Avo::Concerns::HasCoverPhoto + include Avo::Concerns::HasProfilePhoto include Avo::Concerns::HasHelpers include Avo::Concerns::Hydration include Avo::Concerns::Pagination diff --git a/lib/avo/concerns/has_cover_photo.rb b/lib/avo/concerns/has_cover_photo.rb new file mode 100644 index 0000000000..9d29cd2a49 --- /dev/null +++ b/lib/avo/concerns/has_cover_photo.rb @@ -0,0 +1,18 @@ +# Adds the ability to set the visibility of an item in the execution context. +module Avo + module Concerns + module HasCoverPhoto + extend ActiveSupport::Concern + + class_methods do + # Add class property to capture the settings + attr_accessor :cover_photo + end + + # Add instance property to compute the options + def cover_photo + Avo::CoverPhoto.new resource: self + end + end + end +end diff --git a/lib/avo/concerns/has_profile_photo.rb b/lib/avo/concerns/has_profile_photo.rb new file mode 100644 index 0000000000..38b524939a --- /dev/null +++ b/lib/avo/concerns/has_profile_photo.rb @@ -0,0 +1,18 @@ +# Adds the ability to set the visibility of an item in the execution context. +module Avo + module Concerns + module HasProfilePhoto + extend ActiveSupport::Concern + + class_methods do + # Add class property to capture the settings + attr_accessor :profile_photo + end + + # Add instance property to compute the options + def profile_photo + ProfilePhoto.new resource: self + end + end + end +end diff --git a/lib/avo/configuration/branding.rb b/lib/avo/configuration/branding.rb index 16493d1373..1d12ea0c50 100644 --- a/lib/avo/configuration/branding.rb +++ b/lib/avo/configuration/branding.rb @@ -8,7 +8,7 @@ def initialize(colors: nil, chart_colors: nil, logo: nil, logomark: nil, placeho @favicon = favicon @default_colors = { - background: "#F6F6F7", + :background => "#F6F6F7", 100 => "206 231 248", 400 => "57 158 229", 500 => "8 134 222", diff --git a/lib/avo/cover_photo.rb b/lib/avo/cover_photo.rb new file mode 100644 index 0000000000..7ea7c5888a --- /dev/null +++ b/lib/avo/cover_photo.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Avo + class CoverPhoto < PhotoObject + def key = :cover_photo + + def size + options.fetch(:size, :md) + end + end +end diff --git a/lib/avo/photo_object.rb b/lib/avo/photo_object.rb new file mode 100644 index 0000000000..4a8590f7e4 --- /dev/null +++ b/lib/avo/photo_object.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Avo::PhotoObject + def initialize(resource:) + @resource = resource + end + + delegate :record, to: :@resource + delegate :view, to: :@resource + + def options + @options ||= if @resource.class&.send(key).present? + @resource.class&.send(key) + else + {} + end + end + + def value + return unless options.fetch(:source, nil).present? + + if options[:source].is_a?(Symbol) + record.send(options[:source]) + elsif options[:source].respond_to?(:call) + Avo::ExecutionContext.new(target: options[:source], record:, resource: @resource, view:).handle + end + end + + def visible_on + @visible_on ||= Array.wrap(options[:visible_on] || [:show, :forms]) + end + + def visible_in_current_view? + send(:"visible_on_#{view}?") + end + + def present? + value.present? + end + + def visible_on_index? = visible_in_either?(:index, :display) + + def visible_on_show? = visible_in_either?(:show, :display) + + def visible_on_edit? = visible_in_either?(:edit, :forms) + + def visible_on_new? = visible_in_either?(:new, :forms) + + private + + def visible_in_either?(*options) + options.map do |option| + visible_on.include?(option) + end.uniq.first.eql?(true) + end +end diff --git a/lib/avo/profile_photo.rb b/lib/avo/profile_photo.rb new file mode 100644 index 0000000000..85c00d32f6 --- /dev/null +++ b/lib/avo/profile_photo.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Avo + class ProfilePhoto < PhotoObject + def key = :profile_photo + end +end diff --git a/lib/avo/view_inquirer.rb b/lib/avo/view_inquirer.rb index 389a97a56e..88efc72af0 100644 --- a/lib/avo/view_inquirer.rb +++ b/lib/avo/view_inquirer.rb @@ -30,7 +30,7 @@ def ==(other) end def in?(another_object) - super another_object.map(&:to_s) + super(another_object.map(&:to_s)) end end end diff --git a/spec/components/avo/cover_photo_component_spec.rb b/spec/components/avo/cover_photo_component_spec.rb new file mode 100644 index 0000000000..2b9324ecf5 --- /dev/null +++ b/spec/components/avo/cover_photo_component_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe Avo::CoverPhotoComponent, type: :component do + let(:cover_photo) { double("cover_photo") } + let(:resource) { double("resource", cover_photo: cover_photo) } + + describe "rendering" do + context "when cover photo is present and visible in the current view" do + before do + allow(cover_photo).to receive(:present?).and_return(true) + allow(cover_photo).to receive(:visible_in_current_view?).and_return(true) + allow(cover_photo).to receive(:value).and_return("cover_photo_value") + allow(cover_photo).to receive(:size).and_return(:md) + end + + it "renders the component with the correct size and visibility settings" do + render_inline(described_class.new(cover_photo: cover_photo)) + + expect(rendered_component).to have_css(".aspect-cover-md") + expect(rendered_component).to have_selector("img[src='cover_photo_value']") + end + end + + context "when cover photo is not present" do + before do + allow(cover_photo).to receive(:present?).and_return(false) + end + + it "does not render the component" do + render_inline(described_class.new(cover_photo: cover_photo)) + + expect(rendered_component).to be_blank + end + end + + context "when cover photo is not visible in the current view" do + before do + allow(cover_photo).to receive(:visible_in_current_view?).and_return(false) + end + + it "does not render the component" do + render_inline(described_class.new(cover_photo: cover_photo)) + + expect(rendered_component).to be_blank + end + end + + context "when cover photo source is a lambda" do + let(:lambda_source) { -> { "dynamic_source_value" } } + + before do + allow(cover_photo).to receive(:present?).and_return(true) + allow(cover_photo).to receive(:visible_in_current_view?).and_return(true) + allow(cover_photo).to receive(:value).and_return(lambda_source.call) + end + + it "correctly handles dynamic sources" do + render_inline(described_class.new(cover_photo: cover_photo)) + + expect(rendered_component).to have_selector("img[src='dynamic_source_value']") + end + end + end +end diff --git a/spec/dummy/app/avo/resources/event.rb b/spec/dummy/app/avo/resources/event.rb index ef2e90ed18..368ccffff5 100644 --- a/spec/dummy/app/avo/resources/event.rb +++ b/spec/dummy/app/avo/resources/event.rb @@ -3,6 +3,21 @@ class Avo::Resources::Event < Avo::BaseResource self.description = "An event that happened at a certain time." self.includes = [:location] + self.cover_photo = { + # size: :sm, + visible_on: [:show, :index], + source: -> { + if record.present? + record.cover_photo + else + Event.first&.cover_photo + end + } + } + self.profile_photo = { + source: :profile_photo + } + def fields field :name, as: :text, link_to_record: true, sortable: true, stacked: true field :first_user, @@ -17,6 +32,9 @@ def fields foo: :bar, } + field :profile_photo, as: :file, is_image: true + field :cover_photo, as: :file, is_image: true + if params[:show_location_field] == "1" # Example for error message when resource is missing field :location, as: :belongs_to diff --git a/spec/dummy/app/avo/resources/store.rb b/spec/dummy/app/avo/resources/store.rb index 239983dd0a..370ee655e1 100644 --- a/spec/dummy/app/avo/resources/store.rb +++ b/spec/dummy/app/avo/resources/store.rb @@ -6,7 +6,7 @@ def fields field :name, as: :text field :size, as: :text - if params[:show_location_field] == '1' + if params[:show_location_field] == "1" # Example for error message when resource is missing field :location, as: :has_one end diff --git a/spec/dummy/app/avo/resources/team.rb b/spec/dummy/app/avo/resources/team.rb index 8001fbf5a9..7498f9021c 100644 --- a/spec/dummy/app/avo/resources/team.rb +++ b/spec/dummy/app/avo/resources/team.rb @@ -14,9 +14,9 @@ class Avo::Resources::Team < Avo::BaseResource } def fields - field :preview, as: :preview - main_panel do + field :preview, as: :preview + unless params[:hide_id] field :id, as: :id, filterable: true end diff --git a/spec/dummy/app/models/event.rb b/spec/dummy/app/models/event.rb index 5fb719e5c8..9b9ec60bd2 100644 --- a/spec/dummy/app/models/event.rb +++ b/spec/dummy/app/models/event.rb @@ -15,6 +15,9 @@ class Event < ApplicationRecord belongs_to :location, optional: true + has_one_attached :profile_photo + has_one_attached :cover_photo + def first_user User.first end diff --git a/spec/dummy/db/seed_files/dummy-image.jpg b/spec/dummy/db/seed_files/dummy-image.jpg new file mode 100644 index 0000000000..2a09202174 Binary files /dev/null and b/spec/dummy/db/seed_files/dummy-image.jpg differ diff --git a/spec/features/avo/app_spec.rb b/spec/features/avo/app_spec.rb index e75bc6ac25..2d5630f44c 100644 --- a/spec/features/avo/app_spec.rb +++ b/spec/features/avo/app_spec.rb @@ -13,7 +13,7 @@ end describe "Current.user is set" do - it "displayes the current user" do + it "displays the current user" do visit "/admin/custom_tool" # Label on the menu builder diff --git a/spec/features/avo/cover_profile_photos_spec.rb b/spec/features/avo/cover_profile_photos_spec.rb new file mode 100644 index 0000000000..cabf3a3f44 --- /dev/null +++ b/spec/features/avo/cover_profile_photos_spec.rb @@ -0,0 +1,50 @@ +require "rails_helper" + +RSpec.describe "Cover and profile photos", type: :feature do + let!(:event) { + create(:event).tap do |event| + event.profile_photo.attach(io: Avo::Engine.root.join("spec", "dummy", "db", "seed_files", "dummy-image.jpg").open, filename: "dummy-image.jpg") + event.cover_photo.attach(io: Avo::Engine.root.join("spec", "dummy", "db", "seed_files", "dummy-image.jpg").open, filename: "dummy-image.jpg") + end + } + + describe "event page without cover and profile photo" do + it "does not display the cover and profile photo components" do + event.profile_photo.purge + event.cover_photo.purge + visit avo.resources_event_path(event) + + expect(page).not_to have_selector '[data-component="avo/cover_photo_component"]' + expect(page).not_to have_selector '[data-component="avo/profile_photo_component"]' + end + end + + describe "event page with only cover photo" do + it "displays only the cover photo component" do + event.profile_photo.purge + visit avo.resources_event_path(event) + + expect(page).to have_selector '[data-component="avo/cover_photo_component"]' + expect(page).not_to have_selector '[data-component="avo/profile_photo_component"]' + end + end + + describe "event page with only profile photo" do + it "displays only the profile photo component" do + event.cover_photo.purge + visit avo.resources_event_path(event) + + expect(page).not_to have_selector '[data-component="avo/cover_photo_component"]' + expect(page).to have_selector '[data-component="avo/profile_photo_component"]' + end + end + + describe "event page with both cover and profile photos" do + it "displays both the cover and profile photo components" do + visit avo.resources_event_path(event) + + expect(page).to have_selector '[data-component="avo/cover_photo_component"]' + expect(page).to have_selector '[data-component="avo/profile_photo_component"]' + end + end +end diff --git a/tailwind.preset.js b/tailwind.preset.js index 40eaa621dc..3f8fd4ce6f 100644 --- a/tailwind.preset.js +++ b/tailwind.preset.js @@ -27,6 +27,11 @@ module.exports = { ], theme: { extend: { + aspectRatio: { + 'cover-sm': '9/2', + 'cover-md': '9/3', + 'cover-lg': '9/4', + }, colors: { blue, gray, @@ -152,6 +157,8 @@ module.exports = { // Add has-sidebar variant to make it easier to target fields in panels and use the full-width addVariant('has-sidebar', '.has-sidebar & ') addVariant('has-record-selector', '.has-record-selector & ') + addVariant('has-profile-photo', '.has-profile-photo & ') + addVariant('has-cover-photo', '.has-cover-photo & ') addVariant('index-grid-view', '.index-grid-view & ') addVariant('index-table-view', '.index-table-view & ') }),