diff --git a/Gemfile b/Gemfile index 316c3ce6b3..6770914221 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ group :development, :test do # minimal, but breaking. gem "execjs", "= 2.8.1" gem "jsbundling-rails", "~> 1.1" + gem "rubocop", require: false gem "standard", "~> 1.25", require: false gem "selenium-webdriver", "< 4.9.1" # until https://github.com/teamcapybara/capybara/pull/2665 got merged diff --git a/alchemy_cms.gemspec b/alchemy_cms.gemspec index ed40eeb050..d78dec0058 100644 --- a/alchemy_cms.gemspec +++ b/alchemy_cms.gemspec @@ -52,6 +52,7 @@ Gem::Specification.new do |gem| gem.add_runtime_dependency "simple_form", [">= 4.0", "< 6"] gem.add_runtime_dependency "sprockets", [">= 3.0", "< 5"] gem.add_runtime_dependency "turbolinks", [">= 2.5"] + gem.add_runtime_dependency "view_component", ["~> 3.0"] gem.add_development_dependency "capybara", ["~> 3.0"] gem.add_development_dependency "capybara-screenshot", ["~> 1.0"] diff --git a/app/components/alchemy/ingredients/audio_view.rb b/app/components/alchemy/ingredients/audio_view.rb new file mode 100644 index 0000000000..ca993fc63a --- /dev/null +++ b/app/components/alchemy/ingredients/audio_view.rb @@ -0,0 +1,37 @@ +module Alchemy + module Ingredients + class AudioView < BaseView + def call + content_tag(:audio, **html_options) do + tag(:source, src: src, type: type) + end + end + + def render? + !!ingredient.attachment + end + + private + + def src + alchemy.show_attachment_path( + ingredient.attachment, + format: ingredient.attachment.suffix + ) + end + + def type + ingredient.attachment.file_mime_type + end + + def html_options + { + controls: ingredient.controls, + autoplay: ingredient.autoplay, + loop: ingredient.loop, + muted: ingredient.muted + } + end + end + end +end diff --git a/app/components/alchemy/ingredients/base_view.rb b/app/components/alchemy/ingredients/base_view.rb new file mode 100644 index 0000000000..e8e3739f72 --- /dev/null +++ b/app/components/alchemy/ingredients/base_view.rb @@ -0,0 +1,27 @@ +module Alchemy + module Ingredients + class BaseView < ViewComponent::Base + attr_reader :ingredient, :html_options + + delegate :alchemy, to: :helpers + delegate :value, to: :ingredient + + # @param ingredient [Alchemy::Ingredient] + # @param html_options [Hash] Options that will be passed to the wrapper tag. + def initialize(ingredient, html_options: {}) + raise ArgumentError, "Ingredient missing!" if ingredient.nil? + + @ingredient = ingredient + @html_options = html_options + end + + def call + value + end + + def render? + value.present? + end + end + end +end diff --git a/app/components/alchemy/ingredients/boolean_view.rb b/app/components/alchemy/ingredients/boolean_view.rb new file mode 100644 index 0000000000..6bfe8382f8 --- /dev/null +++ b/app/components/alchemy/ingredients/boolean_view.rb @@ -0,0 +1,13 @@ +module Alchemy + module Ingredients + class BooleanView < BaseView + def call + Alchemy.t(value, scope: "ingredient_values.boolean") + end + + def render? + !value.nil? + end + end + end +end diff --git a/app/components/alchemy/ingredients/datetime_view.rb b/app/components/alchemy/ingredients/datetime_view.rb new file mode 100644 index 0000000000..d326eac7db --- /dev/null +++ b/app/components/alchemy/ingredients/datetime_view.rb @@ -0,0 +1,26 @@ +module Alchemy + module Ingredients + class DatetimeView < BaseView + # @param ingredient [Alchemy::Ingredient] + # @param date_format [String] The date format to use. Use either a strftime format string, a I18n format symbol or "rfc822". + def initialize(ingredient, date_format: nil, html_options: {}) + super(ingredient) + @date_format = date_format + end + + def call + if date_format == "rfc822" + ingredient.value.to_s(:rfc822) + else + ::I18n.l(ingredient.value, format: date_format) + end + end + + private + + def date_format + @date_format || ingredient.settings[:date_format] + end + end + end +end diff --git a/app/components/alchemy/ingredients/file_view.rb b/app/components/alchemy/ingredients/file_view.rb new file mode 100644 index 0000000000..08dcbdbe78 --- /dev/null +++ b/app/components/alchemy/ingredients/file_view.rb @@ -0,0 +1,43 @@ +module Alchemy + module Ingredients + class FileView < BaseView + delegate :attachment, to: :ingredient + + # @param ingredient [Alchemy::Ingredient] + # @param link_text [String] The link text. If not given, the ingredients link_text setting or the attachments name will be used. + # @param html_options [Hash] Options that will be passed to the a tag. + def initialize(ingredient, link_text: nil, html_options: {}) + super(ingredient, html_options: html_options) + @link_text = link_text + end + + def call + link_to( + link_text, + attachment.url( + download: true, + name: attachment.slug, + format: attachment.suffix + ), + { + class: ingredient.css_class.presence, + title: ingredient.title.presence + }.merge(html_options) + ) + end + + def render? + !attachment.nil? + end + + private + + def link_text + ingredient.link_text.presence || + ingredient.settings[:link_text].presence || + @link_text.presence || + attachment&.name + end + end + end +end diff --git a/app/components/alchemy/ingredients/headline_view.rb b/app/components/alchemy/ingredients/headline_view.rb new file mode 100644 index 0000000000..9d579ffab9 --- /dev/null +++ b/app/components/alchemy/ingredients/headline_view.rb @@ -0,0 +1,15 @@ +module Alchemy + module Ingredients + class HeadlineView < BaseView + def call + content_tag "h#{ingredient.level}", + ingredient.value, + id: ingredient.dom_id.presence, + class: [ + ingredient.size ? "h#{ingredient.size}" : nil, + html_options[:class] + ] + end + end + end +end diff --git a/app/components/alchemy/ingredients/html_view.rb b/app/components/alchemy/ingredients/html_view.rb new file mode 100644 index 0000000000..060d8f5abb --- /dev/null +++ b/app/components/alchemy/ingredients/html_view.rb @@ -0,0 +1,9 @@ +module Alchemy + module Ingredients + class HtmlView < BaseView + def call + value.to_s.html_safe + end + end + end +end diff --git a/app/components/alchemy/ingredients/link_view.rb b/app/components/alchemy/ingredients/link_view.rb new file mode 100644 index 0000000000..aea1adef35 --- /dev/null +++ b/app/components/alchemy/ingredients/link_view.rb @@ -0,0 +1,29 @@ +module Alchemy + module Ingredients + class LinkView < BaseView + attr_reader :text + + # @param ingredient [Alchemy::Ingredient] + # @param text [String] The link text. If not given, the ingredient's text setting or the value will be used. + # @param html_options [Hash] Options that will be passed to the a tag. + def initialize(ingredient, text: nil, html_options: {}) + super(ingredient, html_options: html_options) + @text = text + end + + def call + link_to(link_text, value, {target: link_target}.merge(html_options)) + end + + private + + def link_text + text || ingredient.settings[:text] || value + end + + def link_target + (ingredient.link_target == "blank") ? "_blank" : nil + end + end + end +end diff --git a/app/components/alchemy/ingredients/node_view.rb b/app/components/alchemy/ingredients/node_view.rb new file mode 100644 index 0000000000..7e1bbc7c2e --- /dev/null +++ b/app/components/alchemy/ingredients/node_view.rb @@ -0,0 +1,11 @@ +module Alchemy + module Ingredients + class NodeView < BaseView + delegate :node, to: :ingredient + + def call + render(node) + end + end + end +end diff --git a/app/components/alchemy/ingredients/page_view.rb b/app/components/alchemy/ingredients/page_view.rb new file mode 100644 index 0000000000..638d4b2541 --- /dev/null +++ b/app/components/alchemy/ingredients/page_view.rb @@ -0,0 +1,15 @@ +module Alchemy + module Ingredients + class PageView < BaseView + delegate :page, to: :ingredient + + def call + link_to page.name, alchemy.show_page_path(urlname: page.urlname) + end + + def render? + !!page + end + end + end +end diff --git a/app/components/alchemy/ingredients/picture_view.rb b/app/components/alchemy/ingredients/picture_view.rb new file mode 100644 index 0000000000..36da54b7e9 --- /dev/null +++ b/app/components/alchemy/ingredients/picture_view.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # Renders a picture ingredient view + class PictureView < BaseView + attr_reader :ingredient, + :show_caption, + :disable_link, + :srcset, + :sizes, + :html_options, + :picture_options, + :picture + + # @param ingredient [Alchemy::Ingredient] + # @param show_caption [Boolean] (true) Whether to show a caption or not, even if present on the picture. + # @param disable_link [Boolean] (false) Whether to disable the link even if the picture has a link. + # @param srcset [Array] An array of srcset sizes that will generate variants of the picture. + # @param sizes [Array] An array of sizes that will be passed to the img tag. + # @param picture_options [Hash] Options that will be passed to the picture url. See {Alchemy::PictureVariant} for options. + # @param html_options [Hash] Options that will be passed to the img tag. + # @see Alchemy::PictureVariant + def initialize( + ingredient, + show_caption: nil, + disable_link: nil, + srcset: nil, + sizes: nil, + picture_options: {}, + html_options: {} + ) + super(ingredient) + @show_caption = show_caption.nil? ? ingredient.settings.fetch(:show_caption, true) : show_caption + @disable_link = disable_link.nil? ? ingredient.settings.fetch(:disable_link, false) : disable_link + @srcset = srcset.nil? ? ingredient.settings.fetch(:srcset, []) : srcset + @sizes = sizes.nil? ? ingredient.settings.fetch(:sizes, []) : sizes + @picture_options = picture_options || {} + @html_options = html_options || {} + @picture = ingredient.picture + end + + def call + return if picture.blank? + + output = caption ? img_tag + caption : img_tag + + if is_linked? + output = link_to(output, url_for(ingredient.link), { + title: ingredient.link_title.presence, + target: (ingredient.link_target == "blank") ? "_blank" : nil, + data: {link_target: ingredient.link_target.presence} + }) + end + + if caption + content_tag(:figure, output, {class: ingredient.css_class.presence}.merge(html_options)) + else + output + end + end + + private + + def caption + return unless show_caption? + + @_caption ||= content_tag(:figcaption, ingredient.caption) + end + + def src + ingredient.picture_url(picture_options) + end + + def img_tag + @_img_tag ||= image_tag( + src, { + alt: alt_text, + title: ingredient.title.presence, + class: caption ? nil : ingredient.css_class.presence, + srcset: srcset_options.join(", ").presence, + sizes: sizes.join(", ").presence + }.merge(caption ? {} : html_options) + ) + end + + def show_caption? + show_caption && ingredient.caption.present? + end + + def is_linked? + !disable_link && ingredient.link.present? + end + + def srcset_options + srcset.map do |size| + url = ingredient.picture_url(size: size) + width, height = size.split("x") + width.present? ? "#{url} #{width}w" : "#{url} #{height}h" + end + end + + def alt_text + ingredient.alt_tag.presence || html_options.delete(:alt) || ingredient.picture.name&.humanize + end + end + end +end diff --git a/app/components/alchemy/ingredients/richtext_view.rb b/app/components/alchemy/ingredients/richtext_view.rb new file mode 100644 index 0000000000..c1fd3c8764 --- /dev/null +++ b/app/components/alchemy/ingredients/richtext_view.rb @@ -0,0 +1,22 @@ +module Alchemy + module Ingredients + class RichtextView < BaseView + attr_reader :plain_text + + # @param ingredient [Alchemy::Ingredient] + # @param plain_text [Boolean] (false) Whether to show as plain text or with markup + def initialize(ingredient, plain_text: nil, html_options: {}) + super(ingredient) + @plain_text = plain_text.nil? ? ingredient.settings.fetch(:plain_text, false) : plain_text + end + + def call + if plain_text + ingredient.stripped_body + else + value.to_s.html_safe + end + end + end + end +end diff --git a/app/components/alchemy/ingredients/select_view.rb b/app/components/alchemy/ingredients/select_view.rb new file mode 100644 index 0000000000..93a8d70461 --- /dev/null +++ b/app/components/alchemy/ingredients/select_view.rb @@ -0,0 +1,6 @@ +module Alchemy + module Ingredients + class SelectView < BaseView + end + end +end diff --git a/app/components/alchemy/ingredients/text_view.rb b/app/components/alchemy/ingredients/text_view.rb new file mode 100644 index 0000000000..d8ebd51d51 --- /dev/null +++ b/app/components/alchemy/ingredients/text_view.rb @@ -0,0 +1,41 @@ +module Alchemy + module Ingredients + class TextView < BaseView + attr_reader :disable_link + + delegate :dom_id, :link, :link_title, :link_target, + to: :ingredient + + # @param ingredient [Alchemy::Ingredient] + # @param disable_link [Boolean] (false) Whether to disable the link even if the picture has a link. + # @param html_options [Hash] Options that will be passed to the a tag. + def initialize(ingredient, disable_link: nil, html_options: {}) + super(ingredient, html_options: html_options) + @disable_link = disable_link.nil? ? ingredient.settings.fetch(:disable_link, false) : disable_link + end + + def call + if disable_link? + dom_id.present? ? anchor : value + else + link_to(value, url_for(link), { + id: dom_id.presence, + title: link_title, + target: ((link_target == "blank") ? "_blank" : nil), + data: {link_target: link_target} + }.merge(html_options)) + end + end + + private + + def anchor + content_tag(:a, value, {id: dom_id}.merge(html_options)) + end + + def disable_link? + link.blank? || disable_link + end + end + end +end diff --git a/app/components/alchemy/ingredients/video_view.rb b/app/components/alchemy/ingredients/video_view.rb new file mode 100644 index 0000000000..be0043677f --- /dev/null +++ b/app/components/alchemy/ingredients/video_view.rb @@ -0,0 +1,39 @@ +module Alchemy + module Ingredients + class VideoView < BaseView + delegate :attachment, to: :ingredient + + def call + content_tag(:video, html_options) do + tag(:source, src: src, type: attachment.file_mime_type) + end + end + + def render? + !attachment.nil? + end + + private + + def src + alchemy.show_attachment_path( + attachment, + format: attachment.suffix + ) + end + + def html_options + { + controls: ingredient.controls, + autoplay: ingredient.autoplay, + loop: ingredient.loop, + muted: ingredient.muted, + playsinline: ingredient.playsinline, + preload: ingredient.preload.presence, + width: ingredient.width.presence, + height: ingredient.height.presence + } + end + end + end +end diff --git a/app/helpers/alchemy/elements_block_helper.rb b/app/helpers/alchemy/elements_block_helper.rb index bc84cb2133..a7139607be 100644 --- a/app/helpers/alchemy/elements_block_helper.rb +++ b/app/helpers/alchemy/elements_block_helper.rb @@ -32,10 +32,12 @@ def render(name, options = {}, html_options = {}) renderable = element.ingredient_by_role(name) return if renderable.nil? - helpers.render(renderable, { - options: options, - html_options: html_options - }) + helpers.render( + renderable.as_view_component( + options: options, + html_options: html_options + ) + ) end # Returns the value of one of the element's ingredients. diff --git a/app/helpers/alchemy/elements_helper.rb b/app/helpers/alchemy/elements_helper.rb index 355626d6c3..8c1963887d 100644 --- a/app/helpers/alchemy/elements_helper.rb +++ b/app/helpers/alchemy/elements_helper.rb @@ -210,5 +210,20 @@ def element_tags_attributes(element, options = {}) {"data-element-tags" => options[:formatter].call(element.tag_list)} end + + def ingredient_view_deprecation_notice(ingredient, file) + Alchemy::Deprecation.warn(<<~WARN) + rendering `alchemy/ingredients/#{file.split("/").last.sub(/^_/, "")}` partial is deprecated. + + Please render the view component directly instead: + + <%= render ingredient.as_view_component %> + + or use the `el.render` helper inside a `element_view_for` helper: + + <%= el.render(:#{ingredient.role}) %> + + WARN + end end end diff --git a/app/models/alchemy/ingredient.rb b/app/models/alchemy/ingredient.rb index cef3243fcc..c6950f7ce6 100644 --- a/app/models/alchemy/ingredient.rb +++ b/app/models/alchemy/ingredient.rb @@ -165,8 +165,20 @@ def preview_ingredient? !!definition[:as_element_title] end + # The view component of the ingredient with mapped options. + # + # @param options [Hash] - Passed to the view component as keyword arguments + # @param html_options [Hash] - Passed to the view component + def as_view_component(options: {}, html_options: {}) + view_component_class.new(self, **options, html_options: html_options) + end + private + def view_component_class + @_view_component_class ||= "#{self.class.name}View".constantize + end + def hint_translation_attribute role end diff --git a/app/models/alchemy/ingredients/picture.rb b/app/models/alchemy/ingredients/picture.rb index fd6720544a..378dd694f5 100644 --- a/app/models/alchemy/ingredients/picture.rb +++ b/app/models/alchemy/ingredients/picture.rb @@ -47,6 +47,24 @@ class Picture < Alchemy::Ingredient def preview_text(max_length = 30) picture&.name.to_s[0..max_length - 1] end + + # The picture view component with mapped options. + # + # @param options [Hash] - Passed to the view component + # @param html_options [Hash] - Passed to the view component + # + # @return Alchemy::Ingredients::PictureView + def as_view_component(options: {}, html_options: {}) + PictureView.new( + self, + show_caption: options.delete(:show_caption), + disable_link: options.delete(:disable_link), + srcset: options.delete(:srcset), + sizes: options.delete(:sizes), + picture_options: options, + html_options: html_options + ) + end end end end diff --git a/app/presenters/alchemy/picture_view.rb b/app/presenters/alchemy/picture_view.rb deleted file mode 100644 index 0c386ecf0f..0000000000 --- a/app/presenters/alchemy/picture_view.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module Alchemy - # Renders a picture ingredient view - class PictureView - include ActionView::Helpers::AssetTagHelper - include ActionView::Helpers::UrlHelper - include Rails.application.routes.url_helpers - - attr_reader :ingredient, :html_options, :options, :picture - - DEFAULT_OPTIONS = { - show_caption: true, - disable_link: false, - srcset: [], - sizes: [] - }.with_indifferent_access - - def initialize(ingredient, options = {}, html_options = {}) - @ingredient = ingredient - @options = DEFAULT_OPTIONS.merge(ingredient.settings).merge(options || {}) - @html_options = html_options || {} - @picture = ingredient.picture - end - - def render - return if picture.blank? - - output = caption ? img_tag + caption : img_tag - - if is_linked? - output = link_to(output, url_for(ingredient.link), { - title: ingredient.link_title.presence, - target: (ingredient.link_target == "blank") ? "_blank" : nil, - data: {link_target: ingredient.link_target.presence} - }) - end - - if caption - content_tag(:figure, output, {class: ingredient.css_class.presence}.merge(html_options)) - else - output - end - end - - def caption - return unless show_caption? - - @_caption ||= content_tag(:figcaption, ingredient.caption) - end - - def src - ingredient.picture_url(options.except(*DEFAULT_OPTIONS.keys)) - end - - def img_tag - @_img_tag ||= image_tag( - src, { - alt: alt_text, - title: ingredient.title.presence, - class: caption ? nil : ingredient.css_class.presence, - srcset: srcset.join(", ").presence, - sizes: options[:sizes].join(", ").presence - }.merge(caption ? {} : html_options) - ) - end - - def show_caption? - options[:show_caption] && ingredient.caption.present? - end - - def is_linked? - !options[:disable_link] && ingredient.link.present? - end - - def srcset - options[:srcset].map do |size| - url = ingredient.picture_url(size: size) - width, height = size.split("x") - width.present? ? "#{url} #{width}w" : "#{url} #{height}h" - end - end - - def alt_text - ingredient.alt_tag.presence || html_options.delete(:alt) || ingredient.picture.name&.humanize - end - end -end diff --git a/app/views/alchemy/ingredients/_audio_view.html.erb b/app/views/alchemy/ingredients/_audio_view.html.erb index f79a25c73c..04bc895971 100644 --- a/app/views/alchemy/ingredients/_audio_view.html.erb +++ b/app/views/alchemy/ingredients/_audio_view.html.erb @@ -1,14 +1,2 @@ -<%- if audio_view.attachment -%> - <%= content_tag :audio, - controls: audio_view.controls, - autoplay: audio_view.autoplay, - loop: audio_view.loop, - muted: audio_view.muted do %> - <%= tag :source, - src: alchemy.show_attachment_path( - audio_view.attachment, - format: audio_view.attachment.suffix - ), - type: audio_view.attachment.file_mime_type %> - <% end %> -<%- end -%> +<%- ingredient_view_deprecation_notice(audio_view, __FILE__) -%> +<%= render audio_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_boolean_view.html.erb b/app/views/alchemy/ingredients/_boolean_view.html.erb index 2eacddbc7a..33070d3040 100644 --- a/app/views/alchemy/ingredients/_boolean_view.html.erb +++ b/app/views/alchemy/ingredients/_boolean_view.html.erb @@ -1 +1,2 @@ -<%= Alchemy.t(boolean_view.value, scope: "ingredient_values.boolean") unless boolean_view.value.nil? -%> +<%- ingredient_view_deprecation_notice(boolean_view, __FILE__) -%> +<%= render boolean_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_datetime_view.html.erb b/app/views/alchemy/ingredients/_datetime_view.html.erb index eca5fa6f35..e6fe5a6407 100644 --- a/app/views/alchemy/ingredients/_datetime_view.html.erb +++ b/app/views/alchemy/ingredients/_datetime_view.html.erb @@ -1,9 +1,4 @@ -<%- date_format = datetime_view.settings_value(:date_format, - local_assigns.fetch(:options, {})) -%> -<%- if datetime_view.value.present? -%> -<%- if date_format == 'rfc822' -%> -<%= datetime_view.value.to_s(:rfc822) %> -<%- else -%> -<%= l(datetime_view.value, format: date_format) %> -<%- end -%> -<%- end -%> +<%- ingredient_view_deprecation_notice(datetime_view, __FILE__) -%> +<%= render datetime_view.as_view_component( + **local_assigns.slice(:options) +) -%> diff --git a/app/views/alchemy/ingredients/_file_view.html.erb b/app/views/alchemy/ingredients/_file_view.html.erb index 8c5661b4a2..30b73f33ea 100644 --- a/app/views/alchemy/ingredients/_file_view.html.erb +++ b/app/views/alchemy/ingredients/_file_view.html.erb @@ -1,17 +1,5 @@ -<%- if attachment = file_view.attachment -%> -<%- html_options = local_assigns.fetch(:html_options, {}) -%> -<%= link_to( - file_view.link_text.presence || - file_view.settings_value(:link_text, local_assigns.fetch(:options, {})) || - attachment.name, - attachment.url( - download: true, - name: attachment.slug, - format: attachment.suffix - ), - { - class: file_view.css_class.presence, - title: file_view.title.presence - }.merge(html_options) +<%- ingredient_view_deprecation_notice(file_view, __FILE__) -%> +<%= render file_view.as_view_component( + **local_assigns.slice(:options), + **local_assigns.slice(:html_options) ) -%> -<%- end -%> diff --git a/app/views/alchemy/ingredients/_headline_view.html.erb b/app/views/alchemy/ingredients/_headline_view.html.erb index 76fd3138f9..95294922a2 100644 --- a/app/views/alchemy/ingredients/_headline_view.html.erb +++ b/app/views/alchemy/ingredients/_headline_view.html.erb @@ -1,10 +1,5 @@ -<%- html_options = local_assigns.fetch(:html_options, {}) -%> - -<%= content_tag "h#{headline_view.level}", - headline_view.value, - id: headline_view.dom_id.presence, - class: [ - headline_view.size ? "h#{headline_view.size}" : nil, - html_options[:class] - ] -%> +<%- ingredient_view_deprecation_notice(headline_view, __FILE__) -%> +<%= render headline_view.as_view_component( + **local_assigns.slice(:options), + **local_assigns.slice(:html_options) +) -%> diff --git a/app/views/alchemy/ingredients/_html_view.html.erb b/app/views/alchemy/ingredients/_html_view.html.erb index f899b1e2a8..524433b1cd 100644 --- a/app/views/alchemy/ingredients/_html_view.html.erb +++ b/app/views/alchemy/ingredients/_html_view.html.erb @@ -1 +1,2 @@ -<%= raw html_view.value -%> +<%- ingredient_view_deprecation_notice(html_view, __FILE__) -%> +<%= render html_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_link_view.html.erb b/app/views/alchemy/ingredients/_link_view.html.erb index e7e54711a8..b1465de67f 100644 --- a/app/views/alchemy/ingredients/_link_view.html.erb +++ b/app/views/alchemy/ingredients/_link_view.html.erb @@ -1,9 +1,5 @@ -<%- if link_view.value.present? -%> -<%- html_options = { - target: link_view.link_target == "blank" ? "_blank" : nil -}.merge(local_assigns.fetch(:html_options, {})) -%> -<%= link_to(link_view.value, html_options) do -%> -<%= link_view.settings_value(:text, local_assigns.fetch(:options, {})) || - link_view.value -%> -<%- end -%> -<%- end -%> +<%- ingredient_view_deprecation_notice(link_view, __FILE__) -%> +<%= render link_view.as_view_component( + **local_assigns.slice(:options), + **local_assigns.slice(:html_options) +) -%> diff --git a/app/views/alchemy/ingredients/_node_view.html.erb b/app/views/alchemy/ingredients/_node_view.html.erb index 99a6c4b4eb..8b92ded1a1 100644 --- a/app/views/alchemy/ingredients/_node_view.html.erb +++ b/app/views/alchemy/ingredients/_node_view.html.erb @@ -1 +1,2 @@ -<%= render node_view.node if node_view.node %> +<%- ingredient_view_deprecation_notice(node_view, __FILE__) -%> +<%= render node_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_page_view.html.erb b/app/views/alchemy/ingredients/_page_view.html.erb index 3848505c95..52c5be81bb 100644 --- a/app/views/alchemy/ingredients/_page_view.html.erb +++ b/app/views/alchemy/ingredients/_page_view.html.erb @@ -1,4 +1,2 @@ -<% page = page_view.page %> -<% if page %> -<%= link_to page.name, alchemy.show_page_path(urlname: page.urlname) %> -<% end %> +<%- ingredient_view_deprecation_notice(page_view, __FILE__) -%> +<%= render page_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_picture_view.html.erb b/app/views/alchemy/ingredients/_picture_view.html.erb index 14c1d770c0..a003b42236 100644 --- a/app/views/alchemy/ingredients/_picture_view.html.erb +++ b/app/views/alchemy/ingredients/_picture_view.html.erb @@ -1,5 +1,5 @@ -<%= Alchemy::PictureView.new( - picture_view, - local_assigns[:options], - local_assigns[:html_options] -).render %> +<%- ingredient_view_deprecation_notice(picture_view, __FILE__) -%> +<%= render picture_view.as_view_component( + **local_assigns.slice(:options), + **local_assigns.slice(:html_options) +) -%> diff --git a/app/views/alchemy/ingredients/_richtext_view.html.erb b/app/views/alchemy/ingredients/_richtext_view.html.erb index 10433c7963..254eec3b00 100644 --- a/app/views/alchemy/ingredients/_richtext_view.html.erb +++ b/app/views/alchemy/ingredients/_richtext_view.html.erb @@ -1,3 +1,4 @@ -<%- options = local_assigns.fetch(:options, {}) -%> -<%- plain_text = !!richtext_view.settings_value(:plain_text, options) -%> -<%= raw richtext_view.public_send(plain_text ? :stripped_body : :value) -%> +<%- ingredient_view_deprecation_notice(richtext_view, __FILE__) -%> +<%= render richtext_view.as_view_component( + **local_assigns.slice(:options) +) -%> diff --git a/app/views/alchemy/ingredients/_select_view.html.erb b/app/views/alchemy/ingredients/_select_view.html.erb index abe3d8edf2..6e3eb51365 100644 --- a/app/views/alchemy/ingredients/_select_view.html.erb +++ b/app/views/alchemy/ingredients/_select_view.html.erb @@ -1 +1,2 @@ -<%= select_view.value %> +<%- ingredient_view_deprecation_notice(select_view, __FILE__) -%> +<%= render select_view.as_view_component -%> diff --git a/app/views/alchemy/ingredients/_text_view.html.erb b/app/views/alchemy/ingredients/_text_view.html.erb index e17a4edaab..e120c10f95 100644 --- a/app/views/alchemy/ingredients/_text_view.html.erb +++ b/app/views/alchemy/ingredients/_text_view.html.erb @@ -1,20 +1,5 @@ -<%- options = local_assigns.fetch(:options, {}) -%> -<%- html_options = local_assigns.fetch(:html_options, {}) -%> -<%- if text_view.link.blank? || text_view.settings_value(:disable_link, options) -%> - <%- if text_view.dom_id.present? -%> - <%= content_tag :a, text_view.value, id: text_view.dom_id %> - <% else %> - <%= text_view.value -%> - <%- end -%> -<%- else -%> - <%= link_to( - text_view.value, - url_for(text_view.link), - { - id: text_view.dom_id.presence, - title: text_view.link_title, - target: (text_view.link_target == "blank" ? "_blank" : nil), - 'data-link-target' => text_view.link_target - }.merge(html_options) +<%- ingredient_view_deprecation_notice(text_view, __FILE__) -%> +<%= render text_view.as_view_component( + **local_assigns.slice(:options), + **local_assigns.slice(:html_options) ) -%> -<%- end -%> diff --git a/app/views/alchemy/ingredients/_video_view.html.erb b/app/views/alchemy/ingredients/_video_view.html.erb index 65c986114e..f644f905b8 100644 --- a/app/views/alchemy/ingredients/_video_view.html.erb +++ b/app/views/alchemy/ingredients/_video_view.html.erb @@ -1,18 +1,2 @@ -<%- if video_view.attachment -%> - <%= content_tag :video, - controls: video_view.controls, - autoplay: video_view.autoplay, - loop: video_view.loop, - muted: video_view.muted, - playsinline: video_view.playsinline, - preload: video_view.preload.presence, - width: video_view.width.presence, - height: video_view.height.presence do %> - <%= tag :source, - src: alchemy.show_attachment_path( - video_view.attachment, - format: video_view.attachment.suffix - ), - type: video_view.attachment.file_mime_type %> - <% end %> -<%- end -%> +<%- ingredient_view_deprecation_notice(video_view, __FILE__) -%> +<%= render video_view.as_view_component -%> diff --git a/lib/alchemy/test_support/shared_ingredient_examples.rb b/lib/alchemy/test_support/shared_ingredient_examples.rb index 50a303f1b9..c2d2079df9 100644 --- a/lib/alchemy/test_support/shared_ingredient_examples.rb +++ b/lib/alchemy/test_support/shared_ingredient_examples.rb @@ -73,4 +73,10 @@ end end end + + describe "#as_view_component" do + subject { ingredient.as_view_component } + + it { is_expected.to be_a("#{described_class}View".constantize) } + end end diff --git a/lib/alchemy_cms.rb b/lib/alchemy_cms.rb index c47ccfcfe8..14bc9c3ed5 100644 --- a/lib/alchemy_cms.rb +++ b/lib/alchemy_cms.rb @@ -23,6 +23,7 @@ require "simple_form" require "turbolinks" require "userstamp" +require "view_component" # Require globally used Alchemy mixins require_relative "alchemy/ability_helper" diff --git a/spec/presenters/alchemy/picture_view_spec.rb b/spec/components/alchemy/ingredients/picture_view_spec.rb similarity index 64% rename from spec/presenters/alchemy/picture_view_spec.rb rename to spec/components/alchemy/ingredients/picture_view_spec.rb index 07f8d6ceb7..10c2ec200d 100644 --- a/spec/presenters/alchemy/picture_view_spec.rb +++ b/spec/components/alchemy/ingredients/picture_view_spec.rb @@ -2,11 +2,9 @@ require "rails_helper" -RSpec.describe Alchemy::PictureView do - include Capybara::RSpecMatchers - +RSpec.describe Alchemy::Ingredients::PictureView, type: :component do let(:image) do - File.new(File.expand_path("../../fixtures/image.png", __dir__)) + File.new(File.expand_path("../../../fixtures/image.png", __dir__)) end let(:picture) do @@ -30,19 +28,6 @@ allow(picture).to receive(:url) { picture_url } end - describe "DEFAULT_OPTIONS" do - subject { Alchemy::PictureView::DEFAULT_OPTIONS } - - it do - is_expected.to eq({ - show_caption: true, - disable_link: false, - srcset: [], - sizes: [] - }.with_indifferent_access) - end - end - context "with caption" do let(:options) do {} @@ -52,22 +37,24 @@ {} end - subject(:view) do - described_class.new(ingredient, options, html_options).render + subject(:render_view) do + render_inline described_class.new(ingredient, **options, html_options: html_options) end it "should enclose the image in a
element" do - expect(view).to have_selector("figure img") + render_view + expect(page).to have_selector("figure img") end it "should show the caption" do - expect(view).to have_selector("figure figcaption") - expect(view).to have_content("This is a cute cat") + render_view + expect(page).to have_selector("figure figcaption") + expect(page).to have_content("This is a cute cat") end it "does not pass default options to picture url" do expect(ingredient).to receive(:picture_url).with({}) { picture_url } - view + render_view end context "but disabled in the options" do @@ -76,39 +63,42 @@ end it "should not enclose the image in a
element" do - expect(view).to_not have_selector("figure img") + render_view + expect(page).to_not have_selector("figure img") end it "should not show the caption" do - expect(view).to_not have_selector("figure figcaption") - expect(view).to_not have_content("This is a cute cat") + render_view + expect(page).to_not have_selector("figure figcaption") + expect(page).to_not have_content("This is a cute cat") end end context "but disabled in the ingredient settings" do before do allow(ingredient).to receive(:settings).and_return({show_caption: false}) + render_view end it "should not enclose the image in a
element" do - expect(view).to_not have_selector("figure img") + expect(page).to_not have_selector("figure img") end it "should not show the caption" do - expect(view).to_not have_selector("figure figcaption") - expect(view).to_not have_content("This is a cute cat") + expect(page).to_not have_selector("figure figcaption") + expect(page).to_not have_content("This is a cute cat") end context "but enabled in the options hash" do let(:options) { {show_caption: true} } it "should enclose the image in a
element" do - expect(view).to have_selector("figure img") + expect(page).to have_selector("figure img") end it "should show the caption" do - expect(view).to have_selector("figure figcaption") - expect(view).to have_content("This is a cute cat") + expect(page).to have_selector("figure figcaption") + expect(page).to have_content("This is a cute cat") end end end @@ -116,32 +106,34 @@ context "and ingredient with css class" do before do ingredient.css_class = "left" + render_view end it "should have the class on the
element" do - expect(view).to have_selector("figure.left img") + expect(page).to have_selector("figure.left img") end it "should not have the class on the element" do - expect(view).not_to have_selector("figure img.left") + expect(page).not_to have_selector("figure img.left") end end context "and css class in the html_options" do before do html_options[:class] = "right" + render_view end it "should have the class from the html_options on the
element" do - expect(view).to have_selector("figure.right img") + expect(page).to have_selector("figure.right img") end it "should not have the class from the ingredient on the
element" do - expect(view).not_to have_selector("figure.left img") + expect(page).not_to have_selector("figure.left img") end it "should not have the class from the html_options on the element" do - expect(view).not_to have_selector("figure img.right") + expect(page).not_to have_selector("figure img.right") end end end @@ -151,41 +143,28 @@ {} end - subject(:view) do + subject(:render_view) do ingredient.link = "/home" - described_class.new(ingredient, options).render + render_inline described_class.new(ingredient, **options) end it "should enclose the image in a link tag" do - expect(view).to have_selector('a[href="/home"] img') + render_view + expect(page).to have_selector('a[href="/home"] img') end context "but disabled link option" do before do options[:disable_link] = true + render_view end it "should not enclose the image in a link tag" do - expect(view).not_to have_selector("a img") + expect(page).not_to have_selector("a img") end end end - context "with multiple instances" do - let(:options) do - {} - end - - subject(:picture_view) do - described_class.new(ingredient, options) - end - - it "does not overwrite DEFAULT_OPTIONS" do - described_class.new(ingredient, {my_custom_option: true}) - expect(picture_view.options).to_not have_key(:my_custom_option) - end - end - context "with srcset ingredient setting" do before do allow(ingredient).to receive(:settings) do @@ -193,8 +172,8 @@ end end - subject(:view) do - described_class.new(ingredient).render + subject(:render_view) do + render_inline described_class.new(ingredient) end let(:srcset) do @@ -203,7 +182,7 @@ it "does not pass srcset option to picture_url" do expect(ingredient).to receive(:picture_url).with({}) { picture_url } - view + render_view end context "when only width or width and height are set" do @@ -214,8 +193,8 @@ it "adds srcset attribute including image url and width for each size" do url1 = ingredient.picture_url(size: "1024x768") url2 = ingredient.picture_url(size: "800x") - - expect(view).to have_selector("img[srcset=\"#{url1} 1024w, #{url2} 800w\"]") + render_view + expect(page).to have_selector("img[srcset=\"#{url1} 1024w, #{url2} 800w\"]") end end @@ -227,19 +206,19 @@ it "adds srcset attribute including image url and height for each size" do url1 = ingredient.picture_url(size: "x768") url2 = ingredient.picture_url(size: "x600") - - expect(view).to have_selector("img[srcset=\"#{url1} 768h, #{url2} 600h\"]") + render_view + expect(page).to have_selector("img[srcset=\"#{url1} 768h, #{url2} 600h\"]") end end end context "with no srcset ingredient setting" do - subject(:view) do - described_class.new(ingredient).render + subject!(:render_view) do + render_inline described_class.new(ingredient) end it "image tag has no srcset attribute" do - expect(view).not_to have_selector("img[srcset]") + expect(page).not_to have_selector("img[srcset]") end end @@ -250,8 +229,8 @@ end end - subject(:view) do - described_class.new(ingredient).render + subject(:render_view) do + render_inline described_class.new(ingredient) end let(:sizes) do @@ -263,27 +242,28 @@ it "does not pass sizes option to picture_url" do expect(ingredient).to receive(:picture_url).with({}) { picture_url } - view + render_view end it "adds sizes attribute for each size" do - expect(view).to have_selector("img[sizes=\"#{sizes[0]}, #{sizes[1]}\"]") + render_view + expect(page).to have_selector("img[sizes=\"#{sizes[0]}, #{sizes[1]}\"]") end end context "with no sizes ingredient setting" do - subject(:view) do - described_class.new(ingredient).render + subject!(:render_view) do + render_inline described_class.new(ingredient) end it "image tag has no sizes attribute" do - expect(view).not_to have_selector("img[sizes]") + expect(page).not_to have_selector("img[sizes]") end end describe "alt text" do - subject(:view) do - described_class.new(ingredient, {}, html_options).render + subject!(:render_view) do + render_inline described_class.new(ingredient, html_options: html_options) end let(:html_options) { {} } @@ -296,7 +276,7 @@ end it "uses this as image alt text" do - expect(view).to have_selector('img[alt="A cute cat"]') + expect(page).to have_selector('img[alt="A cute cat"]') end end @@ -305,7 +285,7 @@ let(:html_options) { {alt: "Cute kittens"} } it "uses this as image alt text" do - expect(view).to have_selector('img[alt="Cute kittens"]') + expect(page).to have_selector('img[alt="Cute kittens"]') end end @@ -319,13 +299,13 @@ end it "uses a humanized picture name as alt text" do - expect(view).to have_selector('img[alt="Cute kitty-cat"]') + expect(page).to have_selector('img[alt="Cute kitty-cat"]') end end context "and no name on the picture" do it "has no alt text" do - expect(view).to_not have_selector("img[alt]") + expect(page).to_not have_selector("img[alt]") end end end diff --git a/spec/controllers/alchemy/pages_controller_spec.rb b/spec/controllers/alchemy/pages_controller_spec.rb index 45c7316723..fff38a9ffa 100644 --- a/spec/controllers/alchemy/pages_controller_spec.rb +++ b/spec/controllers/alchemy/pages_controller_spec.rb @@ -178,7 +178,7 @@ module Alchemy before do allow(Alchemy.user_class).to receive(:admins).and_return(OpenStruct.new(count: 1)) - product.elements.find_by_name("article").ingredients.texts.first.update_column(:value, "screwdriver") + product.elements.find_by(name: "article").ingredients.texts.first.update_column(:value, "screwdriver") end context "with correct levelnames in params" do diff --git a/spec/dummy/app/assets/config/manifest.js b/spec/dummy/app/assets/config/manifest.js index ead0e21b70..5d3a3a53d8 100644 --- a/spec/dummy/app/assets/config/manifest.js +++ b/spec/dummy/app/assets/config/manifest.js @@ -3,3 +3,4 @@ //= link application.css //= link application.js //= link_tree ../builds +//= link_tree ../images diff --git a/spec/dummy/app/assets/images/missing-image.png b/spec/dummy/app/assets/images/missing-image.png new file mode 100644 index 0000000000..30cc07b855 Binary files /dev/null and b/spec/dummy/app/assets/images/missing-image.png differ diff --git a/spec/helpers/alchemy/elements_block_helper_spec.rb b/spec/helpers/alchemy/elements_block_helper_spec.rb index 811991522a..6acd5f3059 100644 --- a/spec/helpers/alchemy/elements_block_helper_spec.rb +++ b/spec/helpers/alchemy/elements_block_helper_spec.rb @@ -68,18 +68,25 @@ module Alchemy end describe "#render" do + let(:scope) { double(render: "") } + context "with element having ingredients" do - let(:element) { create(:alchemy_element, :with_ingredients) } - let(:ingredient) { element.ingredient_by_role(:headline) } + let(:element) { create(:alchemy_element, name: :header, autogenerate_ingredients: true) } + let(:ingredient) { element.ingredient_by_role(:image) } + + it "passes options and html_options to view component class" do + expect(ingredient).to receive(:as_view_component).with( + options: {disable_link: true}, + html_options: {class: "foo"} + ) + subject.render(:image, {disable_link: true}, {class: "foo"}) + end it "delegates to Rails' render helper" do - expect(scope).to receive(:render).with(ingredient, { - options: { - foo: "bar" - }, - html_options: {} - }) - subject.render(:headline, foo: "bar") + expect(scope).to receive(:render).with( + an_instance_of(Alchemy::Ingredients::PictureView) + ) + subject.render(:image) end end end diff --git a/spec/models/alchemy/ingredient_spec.rb b/spec/models/alchemy/ingredient_spec.rb index 2631498bc9..8d892d9000 100644 --- a/spec/models/alchemy/ingredient_spec.rb +++ b/spec/models/alchemy/ingredient_spec.rb @@ -259,4 +259,16 @@ it { is_expected.to be(false) } end + + describe "#as_view_component" do + let(:ingredient) { Alchemy::Ingredients::Text.new(role: "intro", element: element) } + + it "passes options as keyword arguments to view component class" do + expect(Alchemy::Ingredients::TextView).to receive(:new).with(ingredient, disable_link: true, html_options: {class: "foo"}) + ingredient.as_view_component( + options: {disable_link: true}, + html_options: {class: "foo"} + ) + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 0ad6c37df7..ff28580e15 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -15,6 +15,7 @@ require "webdrivers/chromedriver" require "shoulda-matchers" require "factory_bot" +require "view_component/test_helpers" require "alchemy/seeder" require "alchemy/test_support" @@ -79,6 +80,7 @@ end config.include FactoryBot::Syntax::Methods config.include Alchemy::TestSupport::CapybaraHelpers, type: :system + config.include ViewComponent::TestHelpers, type: :component config.use_transactional_fixtures = true diff --git a/spec/views/alchemy/ingredients/boolean_view_spec.rb b/spec/views/alchemy/ingredients/boolean_view_spec.rb index f2c292bb00..892b9d1de8 100644 --- a/spec/views/alchemy/ingredients/boolean_view_spec.rb +++ b/spec/views/alchemy/ingredients/boolean_view_spec.rb @@ -20,4 +20,13 @@ expect(rendered).to have_content("False") end end + + context "with nil as value" do + let(:ingredient) { Alchemy::Ingredients::Boolean.new(value: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end end diff --git a/spec/views/alchemy/ingredients/headline_view_spec.rb b/spec/views/alchemy/ingredients/headline_view_spec.rb index f35f2ffb06..ee6f787253 100644 --- a/spec/views/alchemy/ingredients/headline_view_spec.rb +++ b/spec/views/alchemy/ingredients/headline_view_spec.rb @@ -33,4 +33,11 @@ expect(rendered).to have_selector("h2.h1") end end + + context "with html_options[:class]" do + it "adds class" do + render ingredient, html_options: {class: "bold"} + expect(rendered).to have_selector("h2.bold") + end + end end diff --git a/spec/views/alchemy/ingredients/link_view_spec.rb b/spec/views/alchemy/ingredients/link_view_spec.rb index c0e179bc38..14301faee3 100644 --- a/spec/views/alchemy/ingredients/link_view_spec.rb +++ b/spec/views/alchemy/ingredients/link_view_spec.rb @@ -38,4 +38,11 @@ expect(rendered).to eq('Yahoo') end end + + context "with html options" do + it "renders them" do + render ingredient, html_options: {class: "foo"} + expect(rendered).to eq('http://google.com') + end + end end diff --git a/spec/views/alchemy/ingredients/picture_view_spec.rb b/spec/views/alchemy/ingredients/picture_view_spec.rb index de37a17175..3b79736d95 100644 --- a/spec/views/alchemy/ingredients/picture_view_spec.rb +++ b/spec/views/alchemy/ingredients/picture_view_spec.rb @@ -16,12 +16,28 @@ ) end - before do - expect_any_instance_of(Alchemy::PictureView).to receive(:render).and_call_original + let(:options) do + { + disable_link: true, + show_caption: false, + size: "100x100" + } end - it "renders an image tag" do - render ingredient - expect(rendered).to have_css("img") + it "renders Alchemy::PictureView" do + expect(Alchemy::Ingredients::PictureView).to receive(:new).with( + ingredient, + disable_link: true, + html_options: { + class: "my-picture" + }, + picture_options: { + size: "100x100" + }, + show_caption: false, + sizes: nil, + srcset: nil + ).and_call_original + render ingredient, options: options, html_options: {class: "my-picture"} end end diff --git a/spec/views/alchemy/ingredients/richtext_view_spec.rb b/spec/views/alchemy/ingredients/richtext_view_spec.rb index 9914fa0e77..f73ba05c57 100644 --- a/spec/views/alchemy/ingredients/richtext_view_spec.rb +++ b/spec/views/alchemy/ingredients/richtext_view_spec.rb @@ -35,5 +35,14 @@ is_expected.to have_content("Lorem ipsum dolor sit amet consectetur adipiscing elit.") is_expected.to_not have_selector("h1") end + + context "but options[:plain_text] false" do + let(:options) { {plain_text: false} } + + it "renders the plain text body" do + is_expected.to have_content("Lorem ipsum dolor sit amet consectetur adipiscing elit.") + is_expected.to have_selector("h1") + end + end end end diff --git a/spec/views/alchemy/ingredients/select_view_spec.rb b/spec/views/alchemy/ingredients/select_view_spec.rb index a15b75c25f..795e34dec3 100644 --- a/spec/views/alchemy/ingredients/select_view_spec.rb +++ b/spec/views/alchemy/ingredients/select_view_spec.rb @@ -5,8 +5,17 @@ RSpec.describe "alchemy/ingredients/_select_view" do let(:ingredient) { Alchemy::Ingredients::Select.new(value: "blue") } - it "renders the ingredient" do + it "renders the ingredients value" do render ingredient expect(rendered).to have_content("blue") end + + context "without value" do + let(:ingredient) { Alchemy::Ingredients::Select.new(value: "") } + + it "does not render" do + render ingredient + expect(rendered).to have_content("") + end + end end