diff --git a/Gemfile.lock b/Gemfile.lock index 8a0fa2569..e2f198321 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,7 +178,7 @@ GEM logger (~> 1.5) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) countries (7.1.0) unaccent (~> 0.3) @@ -370,7 +370,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.4) + logger (1.6.5) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -431,7 +431,7 @@ GEM prettier_print (1.2.1) prop_initializer (0.2.0) zeitwerk (>= 2.6.18) - psych (5.2.2) + psych (5.2.3) date stringio public_suffix (6.0.1) @@ -492,7 +492,7 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rbs (2.8.4) - rdoc (6.10.0) + rdoc (6.11.0) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) diff --git a/app/components/avo/discreet_information_component.html.erb b/app/components/avo/discreet_information_component.html.erb new file mode 100644 index 000000000..a584da6c0 --- /dev/null +++ b/app/components/avo/discreet_information_component.html.erb @@ -0,0 +1,7 @@ +
+ <% items.each do |item| %> + <%= content_tag element_tag(item), **element_attributes(item), class: "flex gap-1 text-xs font-normal text-gray-600 hover:text-gray-900", title: item.tooltip, data: {tippy: :tooltip, **data(item)} do %> + <%= item.label if item.label.present? %> <%= helpers.svg item.icon, class: "text-2xl h-4" %> + <% end %> + <% end %> +
diff --git a/app/components/avo/discreet_information_component.rb b/app/components/avo/discreet_information_component.rb new file mode 100644 index 000000000..b356c22d5 --- /dev/null +++ b/app/components/avo/discreet_information_component.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Avo::DiscreetInformationComponent < Avo::BaseComponent + prop :payload + + def items + @payload.items.compact + end + + def element_tag(item) + if item.url.present? + :a + else + :div + end + end + + def element_attributes(item) + if item.url.present? + {href: item.url, target: item.url_target} + else + {} + end + end + + def data(item) = item.data || {} +end diff --git a/app/components/avo/items/panel_component.rb b/app/components/avo/items/panel_component.rb index f8a17cc97..da6791077 100644 --- a/app/components/avo/items/panel_component.rb +++ b/app/components/avo/items/panel_component.rb @@ -35,7 +35,8 @@ def args data: {panel_id: "main"}, cover_photo: @resource.cover_photo, profile_photo: @resource.profile_photo, - external_link: @resource.get_external_link + external_link: @resource.get_external_link, + discreet_information: @resource.discreet_information } 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 a289ee8bc..5b6e9fb4f 100644 --- a/app/components/avo/panel_component.html.erb +++ b/app/components/avo/panel_component.html.erb @@ -5,7 +5,8 @@ description: @description, display_breadcrumbs: @display_breadcrumbs, profile_photo: @profile_photo, - external_link: @external_link + external_link: @external_link, + discreet_information: @discreet_information ) do |header| %> <% if name_slot.present? %> <% header.with_name_slot do %> diff --git a/app/components/avo/panel_component.rb b/app/components/avo/panel_component.rb index c725d49b4..131ff23da 100644 --- a/app/components/avo/panel_component.rb +++ b/app/components/avo/panel_component.rb @@ -19,16 +19,17 @@ class Avo::PanelComponent < Avo::BaseComponent prop :body_classes prop :data, default: {}.freeze prop :display_breadcrumbs, default: false + prop :discreet_information prop :index prop :classes prop :profile_photo prop :cover_photo prop :args, kind: :**, default: {}.freeze + prop :external_link def after_initialize @name = @args.dig(:name) || @args.dig(:title) end - prop :external_link def classes class_names(@classes, "has-cover-photo": @cover_photo.present?, "has-profile-photo": @profile_photo.present?) diff --git a/app/components/avo/panel_header_component.html.erb b/app/components/avo/panel_header_component.html.erb index 7ff8ad993..be42a8a39 100644 --- a/app/components/avo/panel_header_component.html.erb +++ b/app/components/avo/panel_header_component.html.erb @@ -19,6 +19,9 @@ <%= svg "heroicons/outline/arrow-top-right-on-square", class: "ml-2 text-2xl h-4" %> <% end %> <% end %> + <% if @discreet_information.present? %> + <%= render Avo::DiscreetInformationComponent.new(payload: @discreet_information) %> + <% end %> <% end %> <% end %> <% end %> diff --git a/app/components/avo/panel_header_component.rb b/app/components/avo/panel_header_component.rb index 168cc60c2..ca358a625 100644 --- a/app/components/avo/panel_header_component.rb +++ b/app/components/avo/panel_header_component.rb @@ -10,6 +10,7 @@ class Avo::PanelHeaderComponent < Avo::BaseComponent prop :external_link prop :description prop :display_breadcrumbs, default: false + prop :discreet_information prop :profile_photo private diff --git a/app/helpers/avo/turbo_stream_actions_helper.rb b/app/helpers/avo/turbo_stream_actions_helper.rb index a274f02dc..c888e3530 100644 --- a/app/helpers/avo/turbo_stream_actions_helper.rb +++ b/app/helpers/avo/turbo_stream_actions_helper.rb @@ -15,6 +15,10 @@ def avo_close_modal target: Avo::MODAL_FRAME_ID, template: @view_context.turbo_frame_tag(Avo::MODAL_FRAME_ID) end + + def avo_turbo_reload + turbo_stream_action_tag :turbo_reload + end end end diff --git a/app/javascript/avo.base.js b/app/javascript/avo.base.js index 0e36a174f..127aa9112 100644 --- a/app/javascript/avo.base.js +++ b/app/javascript/avo.base.js @@ -25,7 +25,7 @@ Mousetrap.bind('r r r', () => { // Cpture scroll position scrollTop = document.scrollingElement.scrollTop - Turbo.visit(window.location.href, { action: 'replace' }) + window.StreamActions.turbo_reload() }) function isMac() { @@ -56,6 +56,7 @@ document.addEventListener('keyup', (event) => { function initTippy() { tippy('[data-tippy="tooltip"]', { theme: 'light', + allowHTML: true, content(reference) { const title = reference.getAttribute('title') reference.removeAttribute('title') diff --git a/app/javascript/js/custom-stream-actions.js b/app/javascript/js/custom-stream-actions.js index 205f5b965..bab75873d 100644 --- a/app/javascript/js/custom-stream-actions.js +++ b/app/javascript/js/custom-stream-actions.js @@ -8,6 +8,11 @@ StreamActions.close_filters_dropdown = function () { document.querySelector('.filters-dropdown-selector').classList.add('hidden') } +// Uses Turbo to refresh the page +StreamActions.turbo_reload = function () { + window.Turbo.visit(window.location.href, { action: 'replace' }) +} + StreamActions.open_filter = function () { const id = this.getAttribute('unique-id') setTimeout(() => { diff --git a/app/views/avo/actions/show.html.erb b/app/views/avo/actions/show.html.erb index c56ce68fa..9f8ef5ca6 100644 --- a/app/views/avo/actions/show.html.erb +++ b/app/views/avo/actions/show.html.erb @@ -5,7 +5,7 @@ <%= turbo_frame_tag Avo::MODAL_FRAME_ID do %>
" - data-action-no-confirmation-value="<%= @action.no_confirmation %>" + data-action-no-confirmation-value="<%= @action.no_confirmation? %>" data-action-resource-name-value="<%= @resource.model_key %>" data-resource-id="<%= params[:id] %>" class="hidden text-slate-800" diff --git a/lib/avo/base_action.rb b/lib/avo/base_action.rb index 53641a6b8..3b31de11b 100644 --- a/lib/avo/base_action.rb +++ b/lib/avo/base_action.rb @@ -4,6 +4,8 @@ class BaseAction include Avo::Concerns::HasActionStimulusControllers include Avo::Concerns::Hydration + DATA_ATTRIBUTES = {turbo_frame: Avo::MODAL_FRAME_ID} + class_attribute :name, default: nil class_attribute :message class_attribute :confirm_button_label @@ -52,8 +54,8 @@ def to_param to_s end - def link_arguments(resource:, arguments: {}, **args) - path = Avo::Services::URIService.parse(resource.record&.persisted? ? resource.record_path : resource.records_path) + def path(resource:, arguments: {}, **args) + Avo::Services::URIService.parse(resource.record&.persisted? ? resource.record_path : resource.records_path) .append_paths("actions") .append_query( **{ @@ -63,8 +65,10 @@ def link_arguments(resource:, arguments: {}, **args) }.compact ) .to_s + end - [path, {turbo_frame: Avo::MODAL_FRAME_ID}] + def link_arguments(resource:, arguments: {}, **args) + [path(resource:, arguments:, **args), DATA_ATTRIBUTES] end # Encrypt the arguments so we can pass sensible data as a query param. @@ -365,6 +369,16 @@ def disabled? !enabled? end + def no_confirmation? + Avo::ExecutionContext.new( + target: no_confirmation, + action: self, + resource: @resource, + view: @view, + arguments: + ).handle + end + private def add_message(body, type = :info) diff --git a/lib/avo/concerns/has_discreet_information.rb b/lib/avo/concerns/has_discreet_information.rb new file mode 100644 index 000000000..6b521b75f --- /dev/null +++ b/lib/avo/concerns/has_discreet_information.rb @@ -0,0 +1,15 @@ +module Avo + module Concerns + module HasDiscreetInformation + extend ActiveSupport::Concern + + included do + class_attribute :discreet_information, instance_accessor: false + end + + def discreet_information + ::Avo::DiscreetInformation.new resource: self + end + end + end +end diff --git a/lib/avo/discreet_information.rb b/lib/avo/discreet_information.rb new file mode 100644 index 000000000..3e56f7f3d --- /dev/null +++ b/lib/avo/discreet_information.rb @@ -0,0 +1,62 @@ +class Avo::DiscreetInformation + extend PropInitializer::Properties + include ActionView::Helpers::TagHelper + + prop :resource, reader: :public + + delegate :record, :view, to: :resource + + def items + Array.wrap(resource.class.discreet_information).map do |item| + if item == :timestamps + timestamp_item(item) + else + parse_payload(item) + end + end + end + + private + + def timestamp_item(item) + return if record.created_at.blank? && record.updated_at.blank? + + time_format = "%Y-%m-%d %H:%M:%S" + created_at = record.created_at.strftime(time_format) + updated_at = record.updated_at.strftime(time_format) + + created_at_tag = if record.created_at.present? + I18n.t("avo.created_at_timestamp", created_at:) + end + + updated_at_tag = if record.updated_at.present? + I18n.t("avo.updated_at_timestamp", updated_at:) + end + + DiscreetInformationItem.new( + tooltip: tag.div([created_at_tag, updated_at_tag].compact.join(tag.br), style: "text-align: right;"), + icon: "heroicons/outline/clock" + ) + end + + def parse_payload(item) + return unless item.is_a?(Hash) + + args = { + record:, + resource:, + view: + } + + DiscreetInformationItem.new( + tooltip: Avo::ExecutionContext.new(target: item[:tooltip], **args).handle, + icon: Avo::ExecutionContext.new(target: item[:icon], **args).handle, + url: Avo::ExecutionContext.new(target: item[:url], **args).handle, + url_target: Avo::ExecutionContext.new(target: item[:url_target], **args).handle, + data: Avo::ExecutionContext.new(target: item[:data], **args).handle, + label: Avo::ExecutionContext.new(target: item[:label], **args).handle + ) + end + + DiscreetInformationItem = Struct.new(:tooltip, :icon, :url, :url_target, :data, :label, keyword_init: true) unless defined?(DiscreetInformationItem) +end diff --git a/lib/avo/execution_context.rb b/lib/avo/execution_context.rb index d3b8657c8..dedb47740 100644 --- a/lib/avo/execution_context.rb +++ b/lib/avo/execution_context.rb @@ -1,4 +1,7 @@ module Avo + # = Avo Execution Context + # + # The ExecutionContext class is used to evaluate blocks in isolation. class ExecutionContext include Avo::Concerns::HasHelpers @@ -36,7 +39,32 @@ def initialize(**args) delegate :result, to: :card delegate :authorize, to: Avo::Services::AuthorizationService - # Return target if target is not callable, otherwise, execute target on this instance context + # Executes the target and returns the result. + # It takes in a target which usually is a block. If it's something else, it will return it. + # + # It automatically has access to the view context, current user, request, main app, avo, locale, and params. + # It also has a +delegate_missing_to+ which allows it to delegate missing methods to the view context for a more natural experience. + # You may pass extra arguments to the initialize method to have them available in the block that will be executed. + # You may pass extra modules to extend the class with. + # + # ==== Examples + # + # ===== Normal use + # + # Avo::ExecutionContext.new(target: -> { "Hello, world!" }).handle + # => "Hello, world!" + # + # ===== Providing a record + # + # Avo::ExecutionContext.new(target: -> { record.name }, record: @record).handle + # => "John Doe" + # + # ===== Providing a module + # + # This will include the SanitizeHelper module in the class and so have the +sanitize+ method available. + # + # Avo::ExecutionContext.new(target: -> { sanitize "#{record.name}" } record: @record, include: [ActionView::Helpers::SanitizeHelper]).handle + # => "John Doe" def handle target.respond_to?(:call) ? instance_exec(&target) : target end diff --git a/lib/avo/resources/base.rb b/lib/avo/resources/base.rb index 4b3b342e4..be7fd37f5 100644 --- a/lib/avo/resources/base.rb +++ b/lib/avo/resources/base.rb @@ -16,6 +16,7 @@ class Base include Avo::Concerns::Hydration include Avo::Concerns::Pagination include Avo::Concerns::ControlsPlacement + include Avo::Concerns::HasDiscreetInformation # Avo::Current methods delegate :context, to: Avo::Current diff --git a/spec/dummy/app/avo/actions/toggle_published.rb b/spec/dummy/app/avo/actions/toggle_published.rb index 03e4d9802..9f0b136bf 100644 --- a/spec/dummy/app/avo/actions/toggle_published.rb +++ b/spec/dummy/app/avo/actions/toggle_published.rb @@ -3,6 +3,7 @@ class Avo::Actions::TogglePublished < Avo::BaseAction self.message = "Are you sure, sure?" self.confirm_button_label = "Toggle" self.cancel_button_label = "Don't toggle yet" + self.no_confirmation = -> { arguments[:no_confirmation] || false } def fields field :notify_user, as: :boolean, default: true diff --git a/spec/dummy/app/avo/resources/event.rb b/spec/dummy/app/avo/resources/event.rb index 0d2301a91..7ac3dfd6f 100644 --- a/spec/dummy/app/avo/resources/event.rb +++ b/spec/dummy/app/avo/resources/event.rb @@ -17,6 +17,7 @@ class Avo::Resources::Event < Avo::BaseResource self.profile_photo = { source: :profile_photo } + self.discreet_information = :timestamps def fields field :name, as: :text, link_to_record: true, sortable: true, stacked: true diff --git a/spec/dummy/app/avo/resources/post.rb b/spec/dummy/app/avo/resources/post.rb index c7a89c8e5..a84b25c99 100644 --- a/spec/dummy/app/avo/resources/post.rb +++ b/spec/dummy/app/avo/resources/post.rb @@ -33,6 +33,28 @@ class Avo::Resources::Post < Avo::BaseResource main_app.post_path(record) } + self.discreet_information = [ + :timestamps, + { + tooltip: -> { sanitize("Product is #{record.published_at ? "published" : "draft"}", tags: %w[strong]) }, + icon: -> { "heroicons/outline/#{record.published_at ? "eye" : "eye-slash"}" } + }, + { + label: -> { record.published_at ? "✅" : "🙄" }, + tooltip: -> { "Post is #{record.published_at ? "published" : "draft"}. Click to toggle." }, + url: -> { + Avo::Actions::TogglePublished.path( + resource: resource, + arguments: { + records: Array.wrap(record.id), + no_confirmation: true + } + ) + }, + data: Avo::BaseAction::DATA_ATTRIBUTES, + } + ] + def fields field :id, as: :id field :name, required: true, sortable: true diff --git a/spec/dummy/app/avo/resources/product.rb b/spec/dummy/app/avo/resources/product.rb index 442bf323a..92e60daee 100644 --- a/spec/dummy/app/avo/resources/product.rb +++ b/spec/dummy/app/avo/resources/product.rb @@ -5,12 +5,12 @@ class Avo::Resources::Product < Avo::BaseResource self.grid_view = { card: -> do { - cover_url: record.image.attached? ? main_app.url_for(record.image.variant(resize: "300x300")) : nil, + cover_url: record.image.attached? ? main_app.url_for(record.image.variant(resize_to_fill: [300, 300])) : nil, title: record.title, body: simple_format(record.description), - badge_label: (record.updated_at < 1.week.ago) ? "New" : "Updated", - badge_color: (record.updated_at < 1.week.ago) ? "green" : "orange", - badge_title: (record.updated_at < 1.week.ago) ? "New product here" : "Updated product here" + badge_label: (record.status == :new) ? "New" : "Updated", + badge_color: (record.status == :new) ? "green" : "orange", + badge_title: (record.status == :new) ? "New product here" : "Updated product here" } end, html: -> do @@ -28,6 +28,16 @@ class Avo::Resources::Product < Avo::BaseResource self.index_query = -> { query.includes image_attachment: :blob } + self.discreet_information = [ + { + tooltip: -> { sanitize("Product is #{record.status}", tags: %w[strong]) }, + icon: "heroicons/outline/bold" + }, + :timestamps + ] + self.profile_photo = { + source: -> { record.image.attached? ? main_app.url_for(record.image.variant(resize_to_fill: [300, 300])) : nil } + } def fields field :id, as: :id diff --git a/spec/dummy/app/models/product.rb b/spec/dummy/app/models/product.rb index 1cc7d59cc..9afca7e86 100644 --- a/spec/dummy/app/models/product.rb +++ b/spec/dummy/app/models/product.rb @@ -31,4 +31,8 @@ class Product < ApplicationRecord has_one_attached :image has_many_attached :images + + def status + (id % 2).zero? ? :new : :updated + end end diff --git a/spec/dummy/config/locales/avo.en.yml b/spec/dummy/config/locales/avo.en.yml index 94c103638..daba5030b 100644 --- a/spec/dummy/config/locales/avo.en.yml +++ b/spec/dummy/config/locales/avo.en.yml @@ -35,6 +35,8 @@ en: delete_item: Delete %{item} detach_item: detach %{item} details: details + created_at_timestamp: "Created at %{created_at}" + updated_at_timestamp: "Updated at %{updated_at}" download: Download download_file: Download file download_item: Download %{item} diff --git a/yarn.lock b/yarn.lock index 8aaff3b01..76ca2c8fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1504,9 +1504,9 @@ prettier ">=2.3.0" "@rails/actioncable@^7.0": - version "7.0.4" - resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.0.4.tgz#70a3ca56809f7aaabb80af2f9c01ae51e1a8ed41" - integrity sha512-tz4oM+Zn9CYsvtyicsa/AwzKZKL+ITHWkhiu7x+xF77clh2b4Rm+s6xnOgY/sGDWoFWZmtKsE95hxBPkgQQNnQ== + version "7.2.201" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.201.tgz#bfb3da01b3e2462f5a18f372c52dedd7de76037f" + integrity sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw== "@rails/activestorage@^6.1.710": version "6.1.710" @@ -5103,8 +5103,16 @@ stimulus-use@^0.50.0: dependencies: hotkeys-js ">=3" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5204,8 +5212,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -5605,8 +5619,16 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==