diff --git a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee b/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee index 6451431e21..0561df83c6 100644 --- a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee @@ -237,7 +237,7 @@ Alchemy.ElementEditors = # private _shouldUpdateTitle: (element, event) -> - editors = element.find('> .element-content .element-content-editors').children() + editors = element.find('> .element-content .element-content-editors, > .element-content .element-ingredient-editors').children() if @_hasParents(element) editors.length != 0 else if @_isParent(element) && @_isFirstChild $(event.target) diff --git a/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee b/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee index 19753e3822..7470405316 100644 --- a/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee @@ -50,8 +50,8 @@ Alchemy.ElementsWindow = $.get @url, (data) => @element_area.html data Alchemy.GUI.init(@element_area) - Alchemy.fileEditors(@element_area.find(".essence_file, .essence_video, .essence_audio").selector) - Alchemy.pictureEditors(@element_area.find(".essence_picture").selector) + Alchemy.fileEditors(@element_area.find(".essence_file, .essence_video, .essence_audio, .ingredient-editor.file, .ingredient-editor.audio, .ingredient-editor.video").selector) + Alchemy.pictureEditors(@element_area.find(".essence_picture, .ingredient-editor.picture").selector) if @callback @callback.call() .fail (xhr, status, error) => diff --git a/app/assets/javascripts/alchemy/alchemy.gui.js.coffee b/app/assets/javascripts/alchemy/alchemy.gui.js.coffee index e05269f6fa..50bffaba9b 100644 --- a/app/assets/javascripts/alchemy/alchemy.gui.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.gui.js.coffee @@ -23,5 +23,5 @@ Alchemy.GUI = Alchemy.ElementDirtyObserver($el) Alchemy.GUI.init($el) Alchemy.ImageLoader($el[0]) - Alchemy.fileEditors($el.find(".essence_file, .essence_video, .essence_audio").selector) - Alchemy.pictureEditors($el.find(".essence_picture").selector) + Alchemy.fileEditors($el.find(".essence_file, .essence_video, .essence_audio, .ingredient-editor.file, .ingredient-editor.audio, .ingredient-editor.video").selector) + Alchemy.pictureEditors($el.find(".essence_picture, .ingredient-editor.picture").selector) diff --git a/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee index 060b12be42..9b55343dd2 100644 --- a/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee @@ -4,6 +4,12 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog constructor: (@link_object) -> + parent_selector = @link_object.dataset.parentSelector + parent = document.querySelector(parent_selector) + @link_value_field = parent.querySelector("[data-link-value]") + @link_title_field = parent.querySelector("[data-link-title]") + @link_target_field = parent.querySelector("[data-link-target]") + @link_class_field = parent.querySelector("[data-link-class]") @url = Alchemy.routes.link_admin_pages_path @$link_object = $(@link_object) @options = @@ -136,7 +142,7 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog if @link_object.editor @setTinyMCELink(url, title, target) else - @setEssenceLink(url, title, target) + @setLinkFields(url, title, target) # Sets a link in TinyMCE editor. setTinyMCELink: (url, title, target) -> @@ -151,14 +157,16 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog true # Sets a link on an Essence (e.g. EssencePicture). - setEssenceLink: (url, title, target) -> - content_id = @$link_object.data('content-id') - $("#contents_#{content_id}_link").val(url).change() - $("#contents_#{content_id}_link_title").val(title) - $("#contents_#{content_id}_link_class_name").val(@link_type) - $("#contents_#{content_id}_link_target").val(target) - @$link_object.addClass('linked') - @$link_object.next().addClass('linked').removeClass('disabled').removeAttr('tabindex') + setLinkFields: (url, title, target) -> + @link_value_field.value = url + @link_value_field.dispatchEvent(new Event("change")) + @link_title_field.value = title + @link_class_field.value = @link_type + @link_target_field.value = target + @link_object.classList.add("linked") + @link_object.nextElementSibling.classList.replace("disabled", "linked") + @link_object.nextElementSibling.removeAttribute("tabindex") + return # Selects the correct tab for link type and fills all fields. selectTab: -> @@ -205,12 +213,11 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog # Creates a temporay $('a') object that holds all values on it. createTempLink: -> @$tmp_link = $('') - content_id = @$link_object.data('content-id') - @$tmp_link.attr 'href', $("#contents_#{content_id}_link").val() - @$tmp_link.attr 'title', $("#contents_#{content_id}_link_title").val() - @$tmp_link.attr 'data-link-target', $("#contents_#{content_id}_link_target").val() - @$tmp_link.attr 'target', if $("#contents_#{content_id}_link_target").val() == 'blank' then '_blank' else null - @$tmp_link.addClass $("#contents_#{content_id}_link_class_name").val() + @$tmp_link.attr('href', @link_value_field.value) + @$tmp_link.attr('title', @link_title_field.value) + @$tmp_link.attr('data-link-target', @link_target_field.value) + @$tmp_link.attr('target', if @link_target_field.value == 'blank' then '_blank' else null) + @$tmp_link.addClass(@link_class_field.value) @$tmp_link # Validates url for beginning with an protocol. @@ -240,15 +247,21 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog # Public class methods # Removes link from Essence. - @removeLink = (link, content_id) -> - $link = $(link) - $("#contents_#{content_id}_link").val('').change() - $("#contents_#{content_id}_link_title").val('') - $("#contents_#{content_id}_link_class_name").val('') - $("#contents_#{content_id}_link_target").val('') - if $link.hasClass('linked') - Alchemy.setElementDirty $(link).closest('.element-editor') - $link.removeClass('linked').addClass('disabled').attr('tabindex', '-1') - $link.blur() - $('#edit_link_' + content_id).removeClass('linked') + @removeLink = (link, parent_selector) -> + parent = document.querySelector(parent_selector) + link_value_field = parent.querySelector("[data-link-value]") + link_title_field = parent.querySelector("[data-link-title]") + link_target_field = parent.querySelector("[data-link-target]") + link_class_field = parent.querySelector("[data-link-class]") + link_value_field.value = "" + link_value_field.dispatchEvent(new Event("change")) + link_title_field.value = "" + link_class_field.value = "" + link_target_field.value = "" + if link.classList.contains('linked') + Alchemy.setElementDirty link.closest('.element-editor') + link.classList.replace('linked', 'disabled') + link.setAttribute('tabindex', '-1') + link.blur() + link.previousElementSibling.classList.remove("linked") false diff --git a/app/assets/stylesheets/alchemy/elements.scss b/app/assets/stylesheets/alchemy/elements.scss index fb15874d68..792c96cd5f 100644 --- a/app/assets/stylesheets/alchemy/elements.scss +++ b/app/assets/stylesheets/alchemy/elements.scss @@ -280,6 +280,7 @@ } .content_editor, + .ingredient-editor, .picture_thumbnail { width: 100%; } @@ -413,7 +414,8 @@ } } -.element-content-editors { +.element-content-editors, +.element-ingredient-editors { display: flex; flex-wrap: wrap; } @@ -524,7 +526,8 @@ } } -.essence_picture { +.essence_picture, +.ingredient-editor.picture { position: relative; .picture_thumbnail { @@ -559,47 +562,49 @@ } } -.content_editor { - &.essence_audio, - &.essence_file, - &.essence_video { - .file { - display: flex; - align-items: center; - margin: 6px 0 $default-margin; - border: $default-border; - background-color: $white; - border-radius: $default-border-radius; - height: $form-field-height; +.content_editor.essence_audio, +.content_editor.essence_file, +.content_editor.essence_video, +.ingredient-editor.audio, +.ingredient-editor.file, +.ingredient-editor.video { + .file { + display: flex; + align-items: center; + margin: 6px 0 $default-margin; + border: $default-border; + background-color: $white; + border-radius: $default-border-radius; + height: $form-field-height; - .validation_failed & { - color: $error_text_color; - border-color: $error_border_color; - } + .validation_failed & { + color: $error_text_color; + border-color: $error_border_color; } + } - .file_icon { - text-align: center; - width: 24px; - padding: $default-padding; - } + .file_icon { + text-align: center; + width: 24px; + padding: $default-padding; + } - .file_name { - white-space: nowrap; - overflow: hidden; - max-width: 80%; - font-size: $small-font-size; - text-overflow: ellipsis; - padding: $default-padding; - } + .file_name { + white-space: nowrap; + overflow: hidden; + max-width: 80%; + font-size: $small-font-size; + text-overflow: ellipsis; + padding: $default-padding; + } - .remove_file_link { - width: 24px; - padding: $default-padding; - } + .remove_file_link { + width: 24px; + padding: $default-padding; } } +.file_tools, .essence_file_tools { display: flex; align-items: center; @@ -635,7 +640,8 @@ select.long { padding: 0; } -.content_editor { +.content_editor, +.ingredient-editor { width: 100%; padding: $default-padding 0; position: relative; @@ -676,7 +682,7 @@ select.long { } &.validation_failed { - label { + > label { color: $error_text_color; } @@ -727,7 +733,8 @@ select.long { } } - &.essence_select { + &.essence_select, + &.select { label { margin-bottom: 2 * $default-margin; } @@ -741,6 +748,7 @@ select.long { } } + select.ingredient-editor-select, select.essence_editor_select { border-radius: $default-border-radius; background: white; @@ -768,12 +776,14 @@ select.long { } } - &.essence_picture { + &.essence_picture, + &.picture { width: 50%; padding-left: 1px; // Compensate the box shadow padding-right: $default-padding; - + .essence_picture { + + .essence_picture, + + .picture { padding-left: $default-padding; padding-right: 1px; // Compensate the box shadow } @@ -825,6 +835,8 @@ textarea.has_tinymce { .content_editor .hint-with-icon, .content_editor .with-hint, +.ingredient-editor .hint-with-icon, +.ingredient-editor .with-hint, .element-handle .hint-with-icon { margin: 0; @@ -884,6 +896,7 @@ textarea.has_tinymce { } } +.ingredient-date--label, .essence_date--label { position: absolute; right: 7px; diff --git a/app/controllers/alchemy/admin/elements_controller.rb b/app/controllers/alchemy/admin/elements_controller.rb index 2fb0e65af7..b011f621c8 100644 --- a/app/controllers/alchemy/admin/elements_controller.rb +++ b/app/controllers/alchemy/admin/elements_controller.rb @@ -55,13 +55,12 @@ def create # And update all contents in the elements by calling update_contents. # def update - if @element.update_contents(contents_params) - @page = @element.page - @element_validated = @element.update(element_params) + @page = @element.page + + if element_params.key?(:ingredients_attributes) + update_element_with_ingredients else - @element_validated = false - @notice = Alchemy.t("Validation failed") - @error_message = "

#{@notice}

#{Alchemy.t(:content_validations_headline)}

".html_safe + update_element_with_contents end end @@ -105,6 +104,7 @@ def element_includes contents: { essence: :ingredient_association, }, + ingredients: :related_object, }, :tags, { @@ -113,6 +113,7 @@ def element_includes contents: { essence: :ingredient_association, }, + ingredients: :related_object, }, :tags, ], @@ -150,16 +151,36 @@ def contents_params end def element_params - if @element.taggable? - params.fetch(:element, {}).permit(:tag_list) - else - params.fetch(:element, {}) - end + params.fetch(:element).permit(:tag_list, ingredients_attributes: {}) end def create_element_params params.require(:element).permit(:name, :page_version_id, :parent_element_id) end + + def update_element_with_ingredients + if @element.update(element_params) + @element_validated = true + else + element_update_error + @error_messages = @element.ingredient_error_messages + end + end + + def update_element_with_contents + if @element.update_contents(contents_params) + @element_validated = @element.update(element_params) + else + element_update_error + @error_messages = @element.essence_error_messages + end + end + + def element_update_error + @element_validated = false + @notice = Alchemy.t("Validation failed") + @error_message = "

#{@notice}

#{Alchemy.t(:content_validations_headline)}

".html_safe + end end end end diff --git a/app/controllers/alchemy/admin/ingredients_controller.rb b/app/controllers/alchemy/admin/ingredients_controller.rb new file mode 100644 index 0000000000..e0f2c60a9a --- /dev/null +++ b/app/controllers/alchemy/admin/ingredients_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Alchemy + module Admin + class IngredientsController < Alchemy::Admin::BaseController + load_and_authorize_resource class: Alchemy::Ingredient + + include CropAction + + helper "Alchemy::Admin::Ingredients" + + def edit + end + + def update + @ingredient.update(ingredient_params) + end + + private + + def ingredient_params + params.require(:ingredient).permit(@ingredient.class.stored_attributes[:data]) + end + + def load_croppable_resource + @croppable_resource = @ingredient + end + end + end +end diff --git a/app/controllers/alchemy/api/elements_controller.rb b/app/controllers/alchemy/api/elements_controller.rb index 0982d75ce7..5b8c5b2efc 100644 --- a/app/controllers/alchemy/api/elements_controller.rb +++ b/app/controllers/alchemy/api/elements_controller.rb @@ -46,6 +46,7 @@ def element_includes contents: { essence: :ingredient_association, }, + ingredients: :related_object, }, :tags, ], @@ -54,6 +55,7 @@ def element_includes contents: { essence: :ingredient_association, }, + ingredients: :related_object, }, :tags, ] diff --git a/app/decorators/alchemy/element_editor.rb b/app/decorators/alchemy/element_editor.rb index ff6e11903b..a0affbf90b 100644 --- a/app/decorators/alchemy/element_editor.rb +++ b/app/decorators/alchemy/element_editor.rb @@ -19,6 +19,23 @@ def contents end end + # Returns ingredient editor instances for defined ingredients + # + # Creates ingredient on demand if the ingredient is not yet present on the element + # + # @return Array + def ingredients + element.definition.fetch(:ingredients, []).map do |ingredient| + Alchemy::IngredientEditor.new(find_or_create_ingredient(ingredient[:role])) + end + end + + # Are any ingredients defined? + # @return [Boolean] + def has_ingredients_defined? + element.definition.fetch(:ingredients, []).any? + end + # CSS classes for the element editor partial. def css_classes [ @@ -38,7 +55,7 @@ def css_classes def editable? return false if folded? - content_definitions.present? || taggable? + content_definitions.present? || ingredient_definitions.any? || taggable? end # Fixes Rails partial renderer calling to_model on the object @@ -103,5 +120,10 @@ def find_content(name) def create_content(name) Alchemy::Content.create(element: element, name: name) end + + def find_or_create_ingredient(role) + element.ingredients.find { |i| i.role == role } || + Ingredient.create(element: element, role: role) + end end end diff --git a/app/decorators/alchemy/ingredient_editor.rb b/app/decorators/alchemy/ingredient_editor.rb new file mode 100644 index 0000000000..6034827ec1 --- /dev/null +++ b/app/decorators/alchemy/ingredient_editor.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module Alchemy + class IngredientEditor < SimpleDelegator + alias_method :ingredient, :__getobj__ + + def to_partial_path + "alchemy/ingredients/#{partial_name}_editor" + end + + # Returns the translated role for displaying in labels + # + # Translate it in your locale yml file: + # + # alchemy: + # ingredient_roles: + # foo: Bar + # + # Optionally you can scope your ingredient role to an element: + # + # alchemy: + # ingredient_roles: + # article: + # foo: Baz + # + def translated_role + Alchemy.t( + role, + scope: "ingredient_roles.#{element.name}", + default: Alchemy.t("ingredient_roles.#{role}", default: role.humanize), + ) + end + + def css_classes + [ + "ingredient-editor", + partial_name, + deprecated? ? "deprecated" : nil, + ].compact + end + + def data_attributes + { + ingredient_id: id, + ingredient_role: role, + } + end + + # Returns a string to be passed to Rails form field tags to ensure it can be used with Rails' nested attributes. + # + # === Example: + # + # <%= text_field_tag text_editor.form_field_name, text_editor.value %> + # + # === Options: + # + # You can pass an Ingredient column_name. Default is 'value' + # + # ==== Example: + # + # <%= text_field_tag text_editor.form_field_name(:link), text_editor.value %> + # + def form_field_name(column = "value") + "element[ingredients_attributes][#{form_field_counter}][#{column}]" + end + + def form_field_id(column = "value") + "element_ingredients_attributes_#{form_field_counter}_#{column}" + end + + # Fixes Rails partial renderer calling to_model on the object + # which reveals the delegated ingredient instead of this decorator. + def respond_to?(method_name) + return false if method_name == :to_model + + super + end + + def has_warnings? + definition.blank? || deprecated? + end + + def linked? + link.try(:present?) + end + + def warnings + return unless has_warnings? + + if definition.blank? + Logger.warn("ingredient #{role} is missing its definition", caller(1..1)) + Alchemy.t(:ingredient_definition_missing) + else + deprecation_notice + end + end + + # Returns a deprecation notice for ingredients marked deprecated + # + # You can either use localizations or pass a String as notice + # in the ingredient definition. + # + # == Custom deprecation notices + # + # Use general ingredient deprecation notice + # + # - name: element_name + # ingredients: + # - role: old_ingredient + # type: Text + # deprecated: true + # + # Add a translation to your locale file for a per ingredient notice. + # + # en: + # alchemy: + # ingredient_deprecation_notices: + # element_name: + # old_ingredient: Foo baz widget is deprecated + # + # or use the global translation that apply to all deprecated ingredients. + # + # en: + # alchemy: + # ingredient_deprecation_notice: Foo baz widget is deprecated + # + # or pass string as deprecation notice. + # + # - name: element_name + # ingredients: + # - role: old_ingredient + # type: Text + # deprecated: This ingredient will be removed soon. + # + def deprecation_notice + case definition[:deprecated] + when String + definition[:deprecated] + when TrueClass + Alchemy.t( + role, + scope: [:ingredient_deprecation_notices, element.name], + default: Alchemy.t(:ingredient_deprecated), + ) + end + end + + private + + def form_field_counter + element.definition.fetch(:ingredients, []).index { |i| i[:role] == role } + end + end +end diff --git a/app/helpers/alchemy/admin/elements_helper.rb b/app/helpers/alchemy/admin/elements_helper.rb index 56d0aeec66..2c426040e5 100644 --- a/app/helpers/alchemy/admin/elements_helper.rb +++ b/app/helpers/alchemy/admin/elements_helper.rb @@ -3,6 +3,7 @@ module Alchemy module Admin module ElementsHelper + include Alchemy::Admin::IngredientsHelper include Alchemy::Admin::ContentsHelper include Alchemy::Admin::EssencesHelper diff --git a/app/helpers/alchemy/admin/ingredients_helper.rb b/app/helpers/alchemy/admin/ingredients_helper.rb new file mode 100644 index 0000000000..1e438ead4a --- /dev/null +++ b/app/helpers/alchemy/admin/ingredients_helper.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Alchemy + module Admin + module IngredientsHelper + include Alchemy::Admin::BaseHelper + + # Renders the translated role of ingredient. + # + # Displays a warning icon if ingredient is missing its definition. + # + # Displays a mandatory field indicator, if the ingredient has validations. + # + def render_ingredient_role(ingredient) + if ingredient.blank? + warning("Ingredient is nil") + return + end + + content = ingredient.translated_role + + if ingredient.has_warnings? + icon = hint_with_tooltip(ingredient.warnings) + content = "#{icon} #{content}".html_safe + end + + if ingredient.has_validations? + "#{content}*".html_safe + else + content + end + end + + # Renders the label and hint for a ingredient. + def ingredient_label(ingredient, column = :value) + label_tag ingredient.form_field_id(column) do + [render_ingredient_role(ingredient), render_hint_for(ingredient)].compact.join(" ").html_safe + end + end + end + end +end diff --git a/app/helpers/alchemy/elements_block_helper.rb b/app/helpers/alchemy/elements_block_helper.rb index 6d5e0459c8..b880063358 100644 --- a/app/helpers/alchemy/elements_block_helper.rb +++ b/app/helpers/alchemy/elements_block_helper.rb @@ -26,12 +26,13 @@ def element class ElementViewHelper < BlockHelper # Renders one of the element's contents. # + # If the element uses +ingredients+ it renders the ingredient record. + # def render(name, options = {}, html_options = {}) - content = element.content_by_name(name) - return if content.nil? + renderable = element.ingredient_by_role(name) || content(name) + return if renderable.nil? - helpers.render(content, { - content: content, + helpers.render(renderable, { options: options, html_options: html_options, }) @@ -43,16 +44,28 @@ def content(name) element.content_by_name(name) end + deprecate content: "Use `ingredient_by_role` instead", deprecator: Alchemy::Deprecation + # Returns the ingredient of one of the element's contents. # + # If the element uses +ingredients+ it returns the +value+ of the ingredient record. + # def ingredient(name) - element.ingredient(name) + element.ingredient(name).presence || element.ingredient_by_role(name)&.value + end + + deprecate ingredient: :value, deprecator: Alchemy::Deprecation + + # Returns the value of one of the element's ingredients. + # + def value(name) + element.ingredient_by_role(name)&.value end - # Returns true if the given content has been filled by the user. + # Returns true if the given content or ingredient has been filled by the user. # def has?(name) - element.has_ingredient?(name) + element.has_ingredient?(name) || element.has_value_for?(name) end # Return's the given content's essence. @@ -60,6 +73,8 @@ def has?(name) def essence(name) content(name).try(:essence) end + + deprecate essence: "Use `ingredient_by_role` instead", deprecator: Alchemy::Deprecation end # Block-level helper for element views. Constructs a DOM element wrapping diff --git a/app/models/alchemy/element.rb b/app/models/alchemy/element.rb index 4ddf9d198c..4a87cb1c5b 100644 --- a/app/models/alchemy/element.rb +++ b/app/models/alchemy/element.rb @@ -22,6 +22,7 @@ require_dependency "alchemy/element/definitions" require_dependency "alchemy/element/element_contents" +require_dependency "alchemy/element/element_ingredients" require_dependency "alchemy/element/element_essences" require_dependency "alchemy/element/presenters" @@ -39,6 +40,7 @@ class Element < BaseRecord "nestable_elements", "contents", "hint", + "ingredients", "taggable", "compact", "message", @@ -117,6 +119,7 @@ class Element < BaseRecord include Definitions include ElementContents include ElementEssences + include ElementIngredients include Presenters # class methods diff --git a/app/models/alchemy/element/element_contents.rb b/app/models/alchemy/element/element_contents.rb index 7ba642a122..0bc9e8127b 100644 --- a/app/models/alchemy/element/element_contents.rb +++ b/app/models/alchemy/element/element_contents.rb @@ -19,6 +19,7 @@ def content_by_type(essence_type) def contents_by_name(name) contents.select { |content| content.name == name.to_s } end + alias_method :all_contents_by_name, :contents_by_name # All contents from element by given essence type. @@ -27,6 +28,7 @@ def contents_by_type(essence_type) content.essence_type == Content.normalize_essence_type(essence_type) end end + alias_method :all_contents_by_type, :contents_by_type # Updates all related contents by calling +update_essence+ on each of them. @@ -144,7 +146,12 @@ def content_for_rss_meta(type) end # creates the contents for this element as described in the elements.yml + # + # If ingredients are defined as well no contents get created, + # ingredients get created instead. def create_contents + return if definition.fetch(:ingredients, []).any? + definition.fetch("contents", []).each do |attributes| Content.create(attributes.merge(element: self)) end diff --git a/app/models/alchemy/element/element_ingredients.rb b/app/models/alchemy/element/element_ingredients.rb new file mode 100644 index 0000000000..196bda8049 --- /dev/null +++ b/app/models/alchemy/element/element_ingredients.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module Alchemy + class Element < BaseRecord + # Methods concerning ingredients for elements + # + module ElementIngredients + extend ActiveSupport::Concern + + included do + attr_accessor :autogenerate_ingredients + + has_many :ingredients, + class_name: "Alchemy::Ingredient", + inverse_of: :element, + dependent: :destroy + + before_create :build_ingredients, + unless: -> { autogenerate_ingredients == false } + + accepts_nested_attributes_for :ingredients + validates_associated :ingredients, on: :update + end + + # Find first ingredient from element by given role. + def ingredient_by_role(role) + ingredients.detect { |ingredient| ingredient.role == role.to_s } + end + + # Find first ingredient from element by given type. + def ingredient_by_type(type) + ingredients_by_type(type).first + end + + # All ingredients from element by given type. + def ingredients_by_type(type) + ingredients.select do |ingredient| + ingredient.type == Ingredient.normalize_type(type) + end + end + + # Copy current ingredient's ingredients to given target element + def copy_ingredients_to(element) + ingredients.map do |ingredient| + Ingredient.copy(ingredient, element_id: element.id) + end + end + + # Returns all element ingredient definitions from the +elements.yml+ file + def ingredient_definitions + definition.fetch(:ingredients, []) + end + + # Returns the definition for given ingredient role + def ingredient_definition_for(role) + if ingredient_definitions.blank? + log_warning "Element #{name} is missing the ingredient definition for #{role}" + nil + else + ingredient_definitions.find { |d| d[:role] == role.to_s } + end + end + + # Returns an array of all Richtext ingredients ids from elements + # + # This is used to re-initialize the TinyMCE editor in the element editor. + # + def richtext_ingredients_ids + ids = ingredients.select(&:has_tinymce?).collect(&:id) + expanded_nested_elements = nested_elements.expanded + if expanded_nested_elements.present? + ids += expanded_nested_elements.collect(&:richtext_ingredients_ids) + end + ids.flatten + end + + # Has any of the ingredients validations defined? + def has_validations? + ingredients.any?(&:has_validations?) + end + + # All element ingredients where the validation has failed. + def ingredients_with_errors + ingredients.select { |i| i.errors.any? } + end + + # True if the element has a ingredient for given name + # that has a non blank value. + def has_value_for?(role) + ingredient_by_role(role)&.value.present? + end + + # Ingredient validation error messages + # + # == Error messages are translated via I18n + # + # Inside your translation file add translations like: + # + # alchemy: + # ingredient_validations: + # name_of_the_element: + # role_of_the_ingredient: + # validation_error_type: Error Message + # + # NOTE: +validation_error_type+ has to be one of: + # + # * blank + # * taken + # * invalid + # + # === Example: + # + # de: + # alchemy: + # ingredient_validations: + # contactform: + # email: + # invalid: 'Die Email hat nicht das richtige Format' + # + # + # == Error message translation fallbacks + # + # In order to not translate every single ingredient for every element + # you can provide default error messages per content name: + # + # === Example + # + # en: + # alchemy: + # ingredient_validations: + # fields: + # email: + # invalid: E-Mail has wrong format + # blank: E-Mail can't be blank + # + # And even further you can provide general field agnostic error messages: + # + # === Example + # + # en: + # alchemy: + # ingredient_validations: + # errors: + # invalid: %{field} has wrong format + # blank: %{field} can't be blank + # + def ingredient_error_messages + messages = [] + ingredients_with_errors.map { |i| [i.role, i.errors.details] }.each do |role, error_details| + error_details[:value].each do |error_detail| + error = error_detail[:error] + messages << Alchemy.t( + "#{name}.#{role}.#{error}", + scope: "ingredient_validations", + default: [ + "fields.#{role}.#{error}".to_sym, + "errors.#{error}".to_sym, + ], + field: Alchemy::Ingredient.translated_label_for(role, name), + ) + end + end + messages + end + + private + + # Builds ingredients for this element as described in the +elements.yml+ + def build_ingredients + self.ingredients = ingredient_definitions.map do |attributes| + Ingredient.build(role: attributes[:role], element: self) + end + end + end + end +end diff --git a/app/models/alchemy/ingredient.rb b/app/models/alchemy/ingredient.rb new file mode 100644 index 0000000000..d4d22c9b9d --- /dev/null +++ b/app/models/alchemy/ingredient.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +module Alchemy + class Ingredient < BaseRecord + class DefinitionError < StandardError; end + + include Hints + + self.abstract_class = true + self.table_name = "alchemy_ingredients" + + belongs_to :element, touch: true, class_name: "Alchemy::Element", inverse_of: :ingredients + belongs_to :related_object, polymorphic: true, optional: true + + validates :type, presence: true + validates :role, presence: true + + validates_with Alchemy::IngredientValidator, on: :update, if: :has_validations? + + scope :audios, -> { where(type: "Alchemy::Ingredients::Audio") } + scope :booleans, -> { where(type: "Alchemy::Ingredients::Boolean") } + scope :datetimes, -> { where(type: "Alchemy::Ingredients::Datetime") } + scope :files, -> { where(type: "Alchemy::Ingredients::File") } + scope :headlines, -> { where(type: "Alchemy::Ingredients::Headline") } + scope :htmls, -> { where(type: "Alchemy::Ingredients::Html") } + scope :links, -> { where(type: "Alchemy::Ingredients::Link") } + scope :nodes, -> { where(type: "Alchemy::Ingredients::Node") } + scope :pages, -> { where(type: "Alchemy::Ingredients::Page") } + scope :pictures, -> { where(type: "Alchemy::Ingredients::Picture") } + scope :richtexts, -> { where(type: "Alchemy::Ingredients::Richtext") } + scope :selects, -> { where(type: "Alchemy::Ingredients::Select") } + scope :texts, -> { where(type: "Alchemy::Ingredients::Text") } + scope :videos, -> { where(type: "Alchemy::Ingredients::Video") } + + class << self + # Builds concrete ingredient class as described in the +elements.yml+ + def build(attributes = {}) + element = attributes[:element] + raise ArgumentError, "No element given. Please pass element in attributes." if element.nil? + raise ArgumentError, "No role given. Please pass role in attributes." if attributes[:role].nil? + + definition = element.ingredient_definition_for(attributes[:role]) + if definition.nil? + raise DefinitionError, + "No definition found for #{attributes[:role]}. Please define #{attributes[:role]} on #{element[:name]}." + end + + ingredient_class = Ingredient.ingredient_class_by_type(definition[:type]) + ingredient_class.new( + type: Ingredient.normalize_type(definition[:type]), + value: default_value(definition), + role: definition[:role], + element: element, + ) + end + + # Creates concrete ingredient class as described in the +elements.yml+ + def create(attributes = {}) + build(attributes).tap(&:save) + end + + # Defines getter and setter method aliases for related object + # + # @param [String|Symbol] The name of the alias + # @param [String] The class name of the related object + def related_object_alias(name, class_name:) + alias_method name, :related_object + alias_method "#{name}=", :related_object= + + # Somehow Rails STI does not allow us to use `alias_method` for the related_object_id + define_method "#{name}_id" do + related_object_id + end + + define_method "#{name}_id=" do |id| + self.related_object_id = id + self.related_object_type = class_name + end + end + + # Returns an ingredient class by type + # + # Raises ArgumentError if there is no such class in the + # +Alchemy::Ingredients+ module namespace. + # + # If you add custom ingredient class, + # put them in the +Alchemy::Ingredients+ module namespace + # + # @param [String] The ingredient class name to constantize + # @return [Class] + def ingredient_class_by_type(ingredient_type) + Alchemy::Ingredients.const_get(ingredient_type.to_s.classify.demodulize) + end + + # Modulize ingredient type + # + # Makes sure the passed ingredient type is in the +Alchemy::Ingredients+ + # module namespace. + # + # If you add custom ingredient class, + # put them in the +Alchemy::Ingredients+ module namespace + # @param [String] Ingredient class name + # @return [String] + def normalize_type(ingredient_type) + "Alchemy::Ingredients::#{ingredient_type.to_s.classify.demodulize}" + end + + def translated_label_for(role, element_name = nil) + Alchemy.t( + role, + scope: "ingredient_roles.#{element_name}", + default: Alchemy.t("ingredient_roles.#{role}", default: role.humanize), + ) + end + + private + + # Returns the default value from ingredient definition + # + # If the value is a symbol it gets passed through i18n + # inside the +alchemy.default_ingredient_texts+ scope + def default_value(definition) + default = definition[:default] + case default + when Symbol + Alchemy.t(default, scope: :default_ingredient_texts) + else + default + end + end + end + + # Compatibility method for access from element + def essence + self + end + + # The value or the related object if present + def value + related_object || self[:value] + end + + # Settings for this ingredient from the +elements.yml+ definition. + def settings + definition[:settings] || {} + end + + # Fetches value from settings + # + # @param key [Symbol] - The hash key you want to fetch the value from + # @param options [Hash] - An optional Hash that can override the settings. + # Normally passed as options hash into the content + # editor view. + def settings_value(key, options = {}) + settings.merge(options || {})[key.to_sym] + end + + # Definition hash for this ingredient from +elements.yml+ file. + # + def definition + return {} unless element + + element.ingredient_definition_for(role) || {} + end + + # The first 30 characters of the value + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(maxlength = 30) + value.to_s[0..maxlength - 1] + end + + # Cross DB adapter data accessor that works + def data + @_data ||= (self[:data] || {}).with_indifferent_access + end + + # The path to the view partial of the ingredient + # @return [String] + def to_partial_path + "alchemy/ingredients/#{partial_name}_view" + end + + # The demodulized underscored class name of the ingredient + # @return [String] + def partial_name + self.class.name.demodulize.underscore + end + + # @return [Boolean] + def has_validations? + !!definition[:validate] + end + + # @return [Boolean] + def has_hint? + !!definition[:hint] + end + + # @return [Boolean] + def deprecated? + !!definition[:deprecated] + end + + # @return [Boolean] + def has_tinymce? + false + end + + private + + def hint_translation_attribute + role + end + end +end diff --git a/app/models/alchemy/ingredient_validator.rb b/app/models/alchemy/ingredient_validator.rb new file mode 100644 index 0000000000..9f2edb1a60 --- /dev/null +++ b/app/models/alchemy/ingredient_validator.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Alchemy + # Ingredient Validations: + # + # Ingredient validations can be set inside the +config/elements.yml+ file. + # + # Supported validations are: + # + # * presence + # * uniqueness + # * format + # + # *) format needs to come with a regex or a predefined matcher string as its value. + # + # There are already predefined format matchers listed in the +config/alchemy/config.yml+ file. + # It is also possible to add own format matchers there. + # + # Example of format matchers in +config/alchemy/config.yml+: + # + # format_matchers: + # email: !ruby/regexp '/\A[^@\s]+@([^@\s]+\.)+[^@\s]+\z/' + # url: !ruby/regexp '/\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?\z/ix' + # ssl: !ruby/regexp '/https:\/\/[\S]+/' + # + # Example of an element definition with ingredient validations: + # + # - name: person + # ingredients: + # - role: name + # type: Text + # validate: [presence] + # - role: email + # type: Text + # validate: [format: 'email'] + # - role: homepage + # type: Text + # validate: [format: !ruby/regexp '^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$'] + # + # Example of an element definition with chained validations. + # + # - name: person + # ingredients: + # - role: name + # type: Text + # validate: [presence, uniqueness, format: 'name'] + # + class IngredientValidator < ActiveModel::Validator + def validate(ingredient) + @ingredient = ingredient + validations.each do |validation| + if validation.respond_to?(:keys) + validation.map do |key, value| + send("validate_#{key}", value) + end + else + send("validate_#{validation}") + end + end + end + + private + + attr_reader :ingredient + + def validations + ingredient.definition.fetch(:validate, []) + end + + def validate_presence(*) + if ingredient.value.blank? + ingredient.errors.add(:value, :blank) + end + end + + def validate_uniqueness(*) + if duplicates.any? + ingredient.errors.add(:value, :taken) + end + end + + def validate_format(format) + matcher = Alchemy::Config.get("format_matchers")[format] || format + if !ingredient.value.to_s.match?(Regexp.new(matcher)) + ingredient.errors.add(:value, :invalid) + end + end + + def duplicates + ingredient.class + .joins(:element).merge(Alchemy::Element.available) + .where(Alchemy::Element.table_name => { name: ingredient.element.name }) + .where(value: ingredient.value) + .where.not(id: ingredient.id) + end + end +end diff --git a/app/models/alchemy/ingredients/audio.rb b/app/models/alchemy/ingredients/audio.rb new file mode 100644 index 0000000000..81ae185adb --- /dev/null +++ b/app/models/alchemy/ingredients/audio.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A audio attachment + # + class Audio < Alchemy::Ingredient + store_accessor :data, + :autoplay, + :controls, + :muted, + :loop + + related_object_alias :attachment, class_name: "Alchemy::Attachment" + + delegate :name, to: :attachment, allow_nil: true + + # The first 30 characters of the attachments name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/boolean.rb b/app/models/alchemy/ingredients/boolean.rb new file mode 100644 index 0000000000..8784982000 --- /dev/null +++ b/app/models/alchemy/ingredients/boolean.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A boolean value + # + class Boolean < Alchemy::Ingredient + def value + ActiveRecord::Type::Boolean.new.cast(self[:value]) + end + + # The localized value + # + # Used by the Element#preview_text method. + # + def preview_text(_max_length = nil) + Alchemy.t(value, scope: "ingredient_values.boolean") + end + end + end +end diff --git a/app/models/alchemy/ingredients/datetime.rb b/app/models/alchemy/ingredients/datetime.rb new file mode 100644 index 0000000000..8417bae29a --- /dev/null +++ b/app/models/alchemy/ingredients/datetime.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A datetime value + # + class Datetime < Alchemy::Ingredient + def value + ActiveRecord::Type::DateTime.new.cast(self[:value]) + end + + # Returns localized date for the Element#preview_text method. + def preview_text(_maxlength = nil) + return "" unless value + + ::I18n.l(value, format: :'alchemy.essence_date') + end + end + end +end diff --git a/app/models/alchemy/ingredients/file.rb b/app/models/alchemy/ingredients/file.rb new file mode 100644 index 0000000000..726222a69e --- /dev/null +++ b/app/models/alchemy/ingredients/file.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A link to a file + # + # Attach Alchemy::Attachment into this ingredient + # + class File < Alchemy::Ingredient + store_accessor :data, + :css_class, + :link_text, + :title + + related_object_alias :attachment, class_name: "Alchemy::Attachment" + + delegate :name, to: :attachment, allow_nil: true + + # The first 30 characters of the attachments name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + attachment&.name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/headline.rb b/app/models/alchemy/ingredients/headline.rb new file mode 100644 index 0000000000..0e5e3e6582 --- /dev/null +++ b/app/models/alchemy/ingredients/headline.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A text headline + # + class Headline < Alchemy::Ingredient + store_accessor :data, + :level, + :size + + before_create :set_level_and_size + + def preview_text(maxlength = 30) + "H#{level}: #{value}"[0..maxlength - 1] + end + + def level_options + levels.map { |level| ["H#{level}", level] } + end + + def size_options + sizes.map { |size| ["H#{size}", size] } + end + + private + + def levels + settings.fetch(:levels, (1..6)) + end + + def sizes + settings.fetch(:sizes, []) + end + + def set_level_and_size + self.level ||= levels.first + self.size ||= sizes.first + end + end + end +end diff --git a/app/models/alchemy/ingredients/html.rb b/app/models/alchemy/ingredients/html.rb new file mode 100644 index 0000000000..610cfab8cb --- /dev/null +++ b/app/models/alchemy/ingredients/html.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A HTML string + # + class Html < Alchemy::Ingredient + # The first 30 escaped characters from value + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + ::CGI.escapeHTML(value.to_s)[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/link.rb b/app/models/alchemy/ingredients/link.rb new file mode 100644 index 0000000000..f9ac45fa41 --- /dev/null +++ b/app/models/alchemy/ingredients/link.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A URL + # + class Link < Alchemy::Ingredient + store_accessor :data, + :link_class_name, + :link_target, + :link_title + + alias_method :link, :value + end + end +end diff --git a/app/models/alchemy/ingredients/node.rb b/app/models/alchemy/ingredients/node.rb new file mode 100644 index 0000000000..e2c6085778 --- /dev/null +++ b/app/models/alchemy/ingredients/node.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A link to a menu node + # + # Assign Alchemy::Node to this ingredient + # + class Node < Alchemy::Ingredient + related_object_alias :node, class_name: "Alchemy::Node" + + # The first 30 characters of node name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + node&.name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/page.rb b/app/models/alchemy/ingredients/page.rb new file mode 100644 index 0000000000..0f847479ba --- /dev/null +++ b/app/models/alchemy/ingredients/page.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A link to a page + # + # Assign Alchemy::Page to this ingredient + # + class Page < Alchemy::Ingredient + related_object_alias :page, class_name: "Alchemy::Page" + + # The first 30 characters of page name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + page&.name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/picture.rb b/app/models/alchemy/ingredients/picture.rb new file mode 100644 index 0000000000..9038dd6dba --- /dev/null +++ b/app/models/alchemy/ingredients/picture.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A picture assignment + # + # Assign Alchemy::Picture to this ingredient + # + # Optionally you can add a link + # As well as set the alt tag, a caption and title + # + class Picture < Alchemy::Ingredient + include Alchemy::PictureThumbnails + + store_accessor :data, + :alt_tag, + :caption, + :crop_from, + :crop_size, + :css_class, + :link_class_name, + :link_target, + :link_title, + :link, + :render_size, + :title + + related_object_alias :picture, class_name: "Alchemy::Picture" + + # The first 30 characters of the pictures name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + picture&.name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/ingredients/richtext.rb b/app/models/alchemy/ingredients/richtext.rb new file mode 100644 index 0000000000..4ab9690e4d --- /dev/null +++ b/app/models/alchemy/ingredients/richtext.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A blob of richtext + # + class Richtext < Alchemy::Ingredient + store_accessor :data, + :stripped_body, + :sanitized_body + + before_save :strip_content + before_save :sanitize_content + + # The first 30 characters of the stripped_body + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + stripped_body.to_s[0..max_length - 1] + end + + # Returns css class names for the editor textarea. + def tinymce_class_name + "has_tinymce#{has_custom_tinymce_config? ? " #{element.name}_#{role}" : ""}" + end + + def has_tinymce? + true + end + + private + + def strip_content + self.stripped_body = Rails::Html::FullSanitizer.new.sanitize(value) + end + + def sanitize_content + self.sanitized_body = Rails::Html::SafeListSanitizer.new.sanitize( + value, + sanitizer_settings + ) + end + + def sanitizer_settings + settings[:sanitizer] || {} + end + + # Returns true if there is a tinymce setting defined that contains settings. + def has_custom_tinymce_config? + settings[:tinymce].is_a?(Hash) + end + end + end +end diff --git a/app/models/alchemy/ingredients/select.rb b/app/models/alchemy/ingredients/select.rb new file mode 100644 index 0000000000..35fc908528 --- /dev/null +++ b/app/models/alchemy/ingredients/select.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A text value from a select box + # + class Select < Alchemy::Ingredient + end + end +end diff --git a/app/models/alchemy/ingredients/text.rb b/app/models/alchemy/ingredients/text.rb new file mode 100644 index 0000000000..af2c3c445c --- /dev/null +++ b/app/models/alchemy/ingredients/text.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A simple line of text + # + # Optionally it can have a link + # + class Text < Alchemy::Ingredient + store_accessor :data, + :link, + :link_target, + :link_title, + :link_class_name + end + end +end diff --git a/app/models/alchemy/ingredients/video.rb b/app/models/alchemy/ingredients/video.rb new file mode 100644 index 0000000000..39e9c6a526 --- /dev/null +++ b/app/models/alchemy/ingredients/video.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + # A video attachment + # + class Video < Alchemy::Ingredient + store_accessor :data, + :allow_fullscreen, + :autoplay, + :controls, + :height, + :loop, + :muted, + :preload, + :width + + related_object_alias :attachment, class_name: "Alchemy::Attachment" + + delegate :name, to: :attachment, allow_nil: true + + # The first 30 characters of the attachments name + # + # Used by the Element#preview_text method. + # + # @param [Integer] max_length (30) + # + def preview_text(max_length = 30) + name.to_s[0..max_length - 1] + end + end + end +end diff --git a/app/models/alchemy/page/page_elements.rb b/app/models/alchemy/page/page_elements.rb index 3cf7de10fa..2c2be7ca11 100644 --- a/app/models/alchemy/page/page_elements.rb +++ b/app/models/alchemy/page/page_elements.rb @@ -177,6 +177,15 @@ def richtext_contents_ids .collect(&:id) end + # Returns an array of all Richtext ingredients ids from not folded elements + # + def richtext_ingredients_ids + Alchemy::Ingredient.richtexts.joins(:element) + .where(Element.table_name => { page_version_id: draft_version.id, folded: false }) + .select(&:has_tinymce?) + .collect(&:id) + end + private # Looks in the page_layout descripion, if there are elements to autogenerate. diff --git a/app/presenters/alchemy/picture_view.rb b/app/presenters/alchemy/picture_view.rb new file mode 100644 index 0000000000..b99fd7df96 --- /dev/null +++ b/app/presenters/alchemy/picture_view.rb @@ -0,0 +1,88 @@ +# 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/services/alchemy/duplicate_element.rb b/app/services/alchemy/duplicate_element.rb index afaa794601..461f92ea02 100644 --- a/app/services/alchemy/duplicate_element.rb +++ b/app/services/alchemy/duplicate_element.rb @@ -26,11 +26,14 @@ def call(differences = {}) .merge(differences) .merge( autogenerate_contents: false, + autogenerate_ingredients: false, autogenerate_nested_elements: false, tags: source_element.tags, ) - new_element = Element.create(attributes) + new_element = Element.new(attributes) + new_element.ingredients = source_element.ingredients.map(&:dup) + new_element.save! source_element.contents.map do |content| Content.copy(content, element: new_element) diff --git a/app/views/alchemy/admin/elements/_element.html.erb b/app/views/alchemy/admin/elements/_element.html.erb index cfd5a73cab..e3bd427b39 100644 --- a/app/views/alchemy/admin/elements/_element.html.erb +++ b/app/views/alchemy/admin/elements/_element.html.erb @@ -23,11 +23,15 @@ html: {id: "element_#{element.id}_form".html_safe, class: 'element-content'} do |f| %>
- -
- <%= render element.contents %> -
- + <% if element.has_ingredients_defined? %> +
+ <%= render element.ingredients, element_form: f %> +
+ <% else %> +
+ <%= render element.contents %> +
+ <% end %> <% if element.taggable? %>
<%= f.label :tag_list %> diff --git a/app/views/alchemy/admin/elements/update.js.erb b/app/views/alchemy/admin/elements/update.js.erb index 8b4f9c7c17..15c64d4bf6 100644 --- a/app/views/alchemy/admin/elements/update.js.erb +++ b/app/views/alchemy/admin/elements/update.js.erb @@ -1,7 +1,7 @@ (function() { var $el = $('#element_<%= @element.id %>'); var $errors = $('#element_<%= @element.id %>_errors'); - $('> .element-content .content_editor', $el).removeClass('validation_failed'); + $('> .element-content .content_editor, > .element-content .ingredient_editor', $el).removeClass('validation_failed'); <%- if @element_validated -%> @@ -15,9 +15,10 @@ <%- else -%> Alchemy.growl('<%= j @notice %>', 'warn'); - $errors.html('<%= j @error_message %>'); + $errors.html('<%= j @error_message %>'); $errors.show(); $('<%= @element.contents_with_errors.map { |content| "#" + content.dom_id }.join(", ") %>').addClass('validation_failed'); + $('<%== @element.ingredients_with_errors.map { |ingredient| "[data-ingredient-id=\"#{ingredient.id}\"]" }.join(", ") %>').addClass('validation_failed'); Alchemy.Buttons.enable($el); <%- end -%> diff --git a/app/views/alchemy/admin/ingredients/_audio_fields.html.erb b/app/views/alchemy/admin/ingredients/_audio_fields.html.erb new file mode 100644 index 0000000000..052e93f2c7 --- /dev/null +++ b/app/views/alchemy/admin/ingredients/_audio_fields.html.erb @@ -0,0 +1,4 @@ +<%= f.input :autoplay, as: :boolean %> +<%= f.input :controls, as: :boolean %> +<%= f.input :loop, as: :boolean %> +<%= f.input :muted, as: :boolean %> diff --git a/app/views/alchemy/admin/ingredients/_file_fields.html.erb b/app/views/alchemy/admin/ingredients/_file_fields.html.erb new file mode 100644 index 0000000000..ab4a675bfa --- /dev/null +++ b/app/views/alchemy/admin/ingredients/_file_fields.html.erb @@ -0,0 +1,18 @@ +<% css_classes = ingredient.settings[:css_classes] %> + +<%= f.input :link_text %> +<%= f.input :title %> +<%- if css_classes.present? -%> + <%= f.input :css_class, + collection: css_classes, + include_blank: Alchemy.t('None'), + input_html: {class: 'alchemy_selectbox'} %> +<%- else -%> + <%= f.input :css_class, + label: Alchemy.t(:position_in_text), + collection: [ + [Alchemy.t(:above), "no_float"], + [Alchemy.t(:left), "left"], + [Alchemy.t(:right), "right"] + ], include_blank: Alchemy.t('Layout default'), input_html: {class: 'alchemy_selectbox'} %> +<%- end -%> diff --git a/app/views/alchemy/admin/ingredients/_picture_fields.html.erb b/app/views/alchemy/admin/ingredients/_picture_fields.html.erb new file mode 100644 index 0000000000..f0d826e4a1 --- /dev/null +++ b/app/views/alchemy/admin/ingredients/_picture_fields.html.erb @@ -0,0 +1,25 @@ +<%= f.input :caption, as: ingredient.settings[:caption_as_textarea] ? 'text' : 'string' %> +<%= f.input :title %> +<%= f.input :alt_tag %> +<%- if ingredient.settings[:sizes].present? && ingredient.settings[:srcset].blank? -%> + <%= f.input :render_size, + collection: [ + [Alchemy.t('Layout default'), ""] + ] + ingredient.settings[:sizes].to_a, + include_blank: false, + input_html: {class: 'alchemy_selectbox'} %> +<%- end -%> +<%- if ingredient.settings[:css_classes].present? -%> + <%= f.input :css_class, + collection: ingredient.settings[:css_classes], + include_blank: Alchemy.t('None'), + input_html: {class: 'alchemy_selectbox'} %> +<%- else -%> + <%= f.input :css_class, + label: Alchemy.t(:position_in_text), + collection: [ + [Alchemy.t(:above), "no_float"], + [Alchemy.t(:left), "left"], + [Alchemy.t(:right), "right"] + ], include_blank: Alchemy.t("Layout default"), input_html: {class: 'alchemy_selectbox'} %> +<%- end -%> diff --git a/app/views/alchemy/admin/ingredients/_video_fields.html.erb b/app/views/alchemy/admin/ingredients/_video_fields.html.erb new file mode 100644 index 0000000000..e618f26301 --- /dev/null +++ b/app/views/alchemy/admin/ingredients/_video_fields.html.erb @@ -0,0 +1,8 @@ +<%= f.input :width %> +<%= f.input :height %> +<%= f.input :autoplay, as: :boolean %> +<%= f.input :controls, as: :boolean %> +<%= f.input :loop, as: :boolean %> +<%= f.input :muted, as: :boolean %> +<%= f.input :preload, collection: %w(auto none metadata), + include_blank: false, input_html: {class: 'alchemy_selectbox'} %> diff --git a/app/views/alchemy/admin/ingredients/edit.html.erb b/app/views/alchemy/admin/ingredients/edit.html.erb new file mode 100644 index 0000000000..27d5a8c0ff --- /dev/null +++ b/app/views/alchemy/admin/ingredients/edit.html.erb @@ -0,0 +1,4 @@ +<%= alchemy_form_for @ingredient, as: :ingredient, url: alchemy.admin_ingredient_path(@ingredient) do |f| %> + <%= render "alchemy/admin/ingredients/#{@ingredient.partial_name}_fields", ingredient: @ingredient, f: f %> + <%= f.submit Alchemy.t(:save) %> +<% end %> diff --git a/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb b/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb index 850c18f837..09c14da524 100644 --- a/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb +++ b/app/views/alchemy/admin/pages/_tinymce_custom_config.html.erb @@ -1,7 +1,10 @@ diff --git a/app/views/alchemy/ingredients/_link_view.html.erb b/app/views/alchemy/ingredients/_link_view.html.erb new file mode 100644 index 0000000000..e7e54711a8 --- /dev/null +++ b/app/views/alchemy/ingredients/_link_view.html.erb @@ -0,0 +1,9 @@ +<%- 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 -%> diff --git a/app/views/alchemy/ingredients/_node_editor.html.erb b/app/views/alchemy/ingredients/_node_editor.html.erb new file mode 100644 index 0000000000..5d9bdacfab --- /dev/null +++ b/app/views/alchemy/ingredients/_node_editor.html.erb @@ -0,0 +1,25 @@ +<%= content_tag :div, + class: node_editor.css_classes, + data: node_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, node_editor.ingredient) do |f| %> + <%= ingredient_label(node_editor, :node_id) %> + <%= f.text_field :node_id, + value: node_editor.node&.id, + class: 'alchemy_selectbox full_width' %> + <% end %> +<% end %> + + diff --git a/app/views/alchemy/ingredients/_node_view.html.erb b/app/views/alchemy/ingredients/_node_view.html.erb new file mode 100644 index 0000000000..99a6c4b4eb --- /dev/null +++ b/app/views/alchemy/ingredients/_node_view.html.erb @@ -0,0 +1 @@ +<%= render node_view.node if node_view.node %> diff --git a/app/views/alchemy/ingredients/_page_editor.html.erb b/app/views/alchemy/ingredients/_page_editor.html.erb new file mode 100644 index 0000000000..b3fde2f273 --- /dev/null +++ b/app/views/alchemy/ingredients/_page_editor.html.erb @@ -0,0 +1,24 @@ +<%= content_tag :div, + class: page_editor.css_classes, + data: page_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, page_editor.ingredient) do |f| %> + <%= ingredient_label(page_editor, :page_id) %> + <%= f.text_field :page_id, + value: page_editor.page&.id, + class: 'alchemy_selectbox full_width' %> + <% end %> +<% end %> + + diff --git a/app/views/alchemy/ingredients/_page_view.html.erb b/app/views/alchemy/ingredients/_page_view.html.erb new file mode 100644 index 0000000000..3848505c95 --- /dev/null +++ b/app/views/alchemy/ingredients/_page_view.html.erb @@ -0,0 +1,4 @@ +<% page = page_view.page %> +<% if page %> +<%= link_to page.name, alchemy.show_page_path(urlname: page.urlname) %> +<% end %> diff --git a/app/views/alchemy/ingredients/_picture_editor.html.erb b/app/views/alchemy/ingredients/_picture_editor.html.erb new file mode 100644 index 0000000000..2ed11e315f --- /dev/null +++ b/app/views/alchemy/ingredients/_picture_editor.html.erb @@ -0,0 +1,59 @@ +<% options = local_assigns.fetch(:options, {}) %> + +<%= content_tag :div, + class: picture_editor.css_classes, + data: picture_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, picture_editor.ingredient) do |f| %> + <%= ingredient_label(picture_editor, :picture_id) %> + <%= content_tag :div, + data: { + target_size: picture_editor.settings[:size] || [ + picture_editor.image_file_width.to_i, + picture_editor.image_file_height.to_i + ].join("x"), + image_cropper: picture_editor.thumbnail_url_options[:crop], + }, + class: "picture_thumbnail" do %> + + <%= render_icon(:times) %> + +
+
+ <%- if picture_editor.picture -%> + <%= image_tag( + picture_editor.thumbnail_url, + alt: picture_editor.picture.name, + class: "img_paddingtop", + title: Alchemy.t(:image_name, name: picture_editor.picture.name), + ) %> + <% else %> + <%= render_icon(:image, style: "regular") %> + <% end %> +
+
+ <%- if picture_editor.essence.css_class.present? -%> +
+ <%= Alchemy.t("alchemy.essence_pictures.css_classes.#{picture_editor.essence.css_class}", + default: picture_editor.essence.css_class.camelcase) %> +
+ <%- end -%> +
+ <%= render "alchemy/ingredients/shared/picture_tools", { + picture_editor: picture_editor + } %> +
+ <% end %> + <%= f.hidden_field :picture_id, value: picture_editor.picture&.id, + data: { + picture_id: true, + image_file_width: picture_editor.image_file_width, + image_file_height: picture_editor.image_file_height + } %> + <%= f.hidden_field :link, data: { link_value: true } %> + <%= f.hidden_field :link_title, data: { link_title: true } %> + <%= f.hidden_field :link_class_name, data: { link_class: true } %> + <%= f.hidden_field :link_target, data: { link_target: true } %> + <%= f.hidden_field :crop_from, data: { crop_from: true } %> + <%= f.hidden_field :crop_size, data: { crop_size: true } %> + <% end %> +<% end %> diff --git a/app/views/alchemy/ingredients/_picture_view.html.erb b/app/views/alchemy/ingredients/_picture_view.html.erb new file mode 100644 index 0000000000..14c1d770c0 --- /dev/null +++ b/app/views/alchemy/ingredients/_picture_view.html.erb @@ -0,0 +1,5 @@ +<%= Alchemy::PictureView.new( + picture_view, + local_assigns[:options], + local_assigns[:html_options] +).render %> diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb new file mode 100644 index 0000000000..97732bd655 --- /dev/null +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -0,0 +1,12 @@ +<%= content_tag :div, + class: richtext_editor.css_classes, + data: richtext_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> + <%= ingredient_label(richtext_editor) %> +
+ <%= f.text_area :value, + class: richtext_editor.tinymce_class_name, + id: "tinymce_#{richtext_editor.id}" %> +
+ <% end %> +<% end %> diff --git a/app/views/alchemy/ingredients/_richtext_view.html.erb b/app/views/alchemy/ingredients/_richtext_view.html.erb new file mode 100644 index 0000000000..10433c7963 --- /dev/null +++ b/app/views/alchemy/ingredients/_richtext_view.html.erb @@ -0,0 +1,3 @@ +<%- options = local_assigns.fetch(:options, {}) -%> +<%- plain_text = !!richtext_view.settings_value(:plain_text, options) -%> +<%= raw richtext_view.public_send(plain_text ? :stripped_body : :value) -%> diff --git a/app/views/alchemy/ingredients/_select_editor.html.erb b/app/views/alchemy/ingredients/_select_editor.html.erb new file mode 100644 index 0000000000..65065c9688 --- /dev/null +++ b/app/views/alchemy/ingredients/_select_editor.html.erb @@ -0,0 +1,29 @@ +<% select_values = select_editor.settings[:select_values] %> + +<%= content_tag :div, + class: [ + select_editor.css_classes, + select_editor.settings[:display_inline] ? 'display_inline' : '' + ], data: select_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, select_editor.ingredient) do |f| %> + <%= ingredient_label(select_editor) %> + + <% if select_values.nil? %> + <%= warning(':select_values is nil', + "No select values given. +
Please provide select_values on the + content definition settings in + elements.yml.") %> + <% else %> + <% + if select_values.is_a?(Hash) + options_tags = grouped_options_for_select(select_values, select_editor.value) + else + options_tags = options_for_select(select_values, select_editor.value) + end %> + <%= f.select :value, options_tags, {}, { + class: ["alchemy_selectbox", "ingredient-editor-select"] + } %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/alchemy/ingredients/_select_view.html.erb b/app/views/alchemy/ingredients/_select_view.html.erb new file mode 100644 index 0000000000..abe3d8edf2 --- /dev/null +++ b/app/views/alchemy/ingredients/_select_view.html.erb @@ -0,0 +1 @@ +<%= select_view.value %> diff --git a/app/views/alchemy/ingredients/_text_editor.html.erb b/app/views/alchemy/ingredients/_text_editor.html.erb new file mode 100644 index 0000000000..61da1de59f --- /dev/null +++ b/app/views/alchemy/ingredients/_text_editor.html.erb @@ -0,0 +1,19 @@ +<%= content_tag :div, + class: [ + text_editor.css_classes, + text_editor.settings[:display_inline] ? "display_inline" : "" + ], data: text_editor.data_attributes do %> + <%= element_form.fields_for(:ingredients, text_editor.ingredient) do |f| %> + <%= ingredient_label(text_editor) %> + <%= f.text_field :value, + class: text_editor.settings[:linkable] ? "text_with_icon" : "", + type: text_editor.settings[:input_type] || "text" %> + <% if text_editor.settings[:linkable] %> + <%= f.hidden_field :link, "data-link-value": true %> + <%= f.hidden_field :link_title, "data-link-title": true %> + <%= f.hidden_field :link_class_name, "data-link-class": true%> + <%= f.hidden_field :link_target, "data-link-target": true %> + <%= render "alchemy/ingredients/shared/link_tools", ingredient_editor: text_editor %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/alchemy/ingredients/_text_view.html.erb b/app/views/alchemy/ingredients/_text_view.html.erb new file mode 100644 index 0000000000..00f48e3777 --- /dev/null +++ b/app/views/alchemy/ingredients/_text_view.html.erb @@ -0,0 +1,16 @@ +<%- options = local_assigns.fetch(:options, {}) -%> +<%- html_options = local_assigns.fetch(:html_options, {}) -%> +<%- if text_view.link.blank? || + text_view.settings_value(:disable_link, options) -%> +<%= text_view.value -%> +<%- else -%> + <%= link_to( + text_view.value, + url_for(text_view.link), + { + title: text_view.link_title, + target: (text_view.link_target == "blank" ? "_blank" : nil), + 'data-link-target' => text_view.link_target + }.merge(html_options) +) -%> +<%- end -%> diff --git a/app/views/alchemy/ingredients/_video_editor.html.erb b/app/views/alchemy/ingredients/_video_editor.html.erb new file mode 100644 index 0000000000..8af3980b3c --- /dev/null +++ b/app/views/alchemy/ingredients/_video_editor.html.erb @@ -0,0 +1,5 @@ +<%= render( + "alchemy/ingredients/file_editor", + element_form: element_form, + file_editor: video_editor, +) %> diff --git a/app/views/alchemy/ingredients/_video_view.html.erb b/app/views/alchemy/ingredients/_video_view.html.erb new file mode 100644 index 0000000000..e4a22ae641 --- /dev/null +++ b/app/views/alchemy/ingredients/_video_view.html.erb @@ -0,0 +1,17 @@ +<%- if video_view.attachment -%> + <%= content_tag :video, + controls: video_view.controls, + autoplay: video_view.autoplay, + loop: video_view.loop, + muted: video_view.muted, + 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 -%> diff --git a/app/views/alchemy/ingredients/shared/_link_tools.html.erb b/app/views/alchemy/ingredients/shared/_link_tools.html.erb new file mode 100644 index 0000000000..bce06a6242 --- /dev/null +++ b/app/views/alchemy/ingredients/shared/_link_tools.html.erb @@ -0,0 +1,20 @@ + + <%= link_to( + render_icon(:link), + '#', + onclick: 'new Alchemy.LinkDialog(this).open(); return false;', + class: "icon_button#{ingredient_editor.linked? ? ' linked' : ''} link-essence", + "data-parent-selector": "[data-ingredient-id='#{ingredient_editor.id}']", + title: Alchemy.t(:place_link), + id: "edit_link_#{ingredient_editor.id}" + ) %> + <%= link_to( + render_icon(:unlink), + '#', + onclick: "return Alchemy.LinkDialog.removeLink(this, '[data-ingredient-id=\"#{ingredient_editor.id}\"]')", + class: "icon_button unlink-essence #{ingredient_editor.linked? ? 'linked' : 'disabled'}", + tabindex: ingredient_editor.linked? ? nil : '-1', + 'data-ingredient-id' => ingredient_editor.id, + title: Alchemy.t(:unlink) + ) %> + diff --git a/app/views/alchemy/ingredients/shared/_picture_tools.html.erb b/app/views/alchemy/ingredients/shared/_picture_tools.html.erb new file mode 100644 index 0000000000..2dc4142af0 --- /dev/null +++ b/app/views/alchemy/ingredients/shared/_picture_tools.html.erb @@ -0,0 +1,57 @@ +<% linkable = picture_editor.settings[:linkable] != false %> +<% croppable = picture_editor.allow_image_cropping? %> + +<%= link_to_dialog render_icon(:crop), + alchemy.crop_admin_ingredient_path(picture_editor.ingredient, { + crop_from_form_field_id: picture_editor.form_field_id(:crop_from), + crop_size_form_field_id: picture_editor.form_field_id(:crop_size), + picture_id: picture_editor.picture&.id + }), { + size: "1080x615", + title: Alchemy.t("Edit Picturemask"), + image_loader: false, + padding: false + }, { + title: Alchemy.t("Edit Picturemask"), + class: croppable ? "crop_link" : "disabled crop_link", + tabindex: croppable ? nil : "-1", + onclick: "return false" + } %> + +<%= link_to_dialog render_icon("file-image", style: "regular"), + alchemy.admin_pictures_path( + form_field_id: picture_editor.form_field_id(:picture_id) + ), + { + title: (picture_editor.picture ? Alchemy.t(:swap_image) : Alchemy.t(:insert_image)), + size: "790x590", + padding: false + }, + title: (picture_editor.picture ? Alchemy.t(:swap_image) : Alchemy.t(:insert_image)) %> + +<%= link_to_if linkable, render_icon(:link), "", { + onclick: "new Alchemy.LinkDialog(this).open(); return false;", + class: picture_editor.linked? ? "linked" : nil, + title: Alchemy.t(:link_image), + "data-parent-selector": "[data-ingredient-id='#{picture_editor.id}']", + id: "edit_link_#{picture_editor.id}" +} do %> + <%= render_icon(:link) %> +<% end %> + +<%= link_to_if linkable, render_icon(:unlink), "", { + onclick: "return Alchemy.LinkDialog.removeLink(this, '[data-ingredient-id=\"#{picture_editor.id}\"]')", + class: picture_editor.linked? ? "linked" : "disabled", + tabindex: picture_editor.linked? ? nil : "-1", + title: Alchemy.t(:unlink) +} do %> + <%= render_icon(:unlink) %> +<% end %> + +<%= link_to_dialog render_icon(:edit), + alchemy.edit_admin_ingredient_path(id: picture_editor.id), + { + title: Alchemy.t(:edit_image_properties), + size: "380x255" + }, + title: Alchemy.t(:edit_image_properties) %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index aee428dd6f..b3781127bb 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -3,19 +3,19 @@ { "warning_type": "Cross-Site Scripting", "warning_code": 2, - "fingerprint": "0551e3f9180b85fca4b17fe3c7cbbac1611d2ef8d385f77e9445c562c471d688", + "fingerprint": "068b12d24047e2ece633115ba065ce46fc8c8a26827be7de2565ab721e1c2e82", "check_name": "CrossSiteScripting", "message": "Unescaped parameter value", "file": "app/views/alchemy/admin/elements/update.js.erb", - "line": 18, + "line": 21, "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting", - "code": "j(Element.find(params[:id]).essence_error_messages.join(\"
  • \"))", + "code": "Element.find(params[:id]).ingredients_with_errors.map do\n \"[data-ingredient-id=\\\"#{ingredient.id}\\\"]\"\n end.join(\", \")", "render_path": [ { "type": "controller", "class": "Alchemy::Admin::ElementsController", "method": "update", - "line": 59, + "line": 61, "file": "app/controllers/alchemy/admin/elements_controller.rb", "rendered": { "name": "alchemy/admin/elements/update", @@ -38,7 +38,7 @@ "check_name": "SendFile", "message": "Parameter value used in file name", "file": "app/controllers/alchemy/admin/attachments_controller.rb", - "line": 65, + "line": 69, "link": "https://brakemanscanner.org/docs/warning_types/file_access/", "code": "send_file(Attachment.find(params[:id]).file.path, :filename => Attachment.find(params[:id]).file_name, :type => Attachment.find(params[:id]).file_mime_type)", "render_path": null, @@ -71,37 +71,6 @@ "confidence": "Medium", "note": "Because we actually can't know all attributes each inheriting controller supports, we permit all resource model params. It is adviced that all inheriting controllers implement this method and provide its own set of permitted attributes. As this all happens inside the password protected /admin namespace this can be considered a false positive." }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "2fa9bf5c73b4e6e3c272f0b14635f96efbd763e9a2c5b785caefffe3589ac461", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/alchemy/admin/essence_pictures/assign.js.erb", - "line": 2, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => Alchemy::ContentEditor.new(Content.find(params[:content_id])), {})", - "render_path": [ - { - "type": "controller", - "class": "Alchemy::Admin::EssencePicturesController", - "method": "assign", - "line": 49, - "file": "app/controllers/alchemy/admin/essence_pictures_controller.rb", - "rendered": { - "name": "alchemy/admin/essence_pictures/assign", - "file": "app/views/alchemy/admin/essence_pictures/assign.js.erb" - } - } - ], - "location": { - "type": "template", - "template": "alchemy/admin/essence_pictures/assign" - }, - "user_input": "params[:content_id]", - "confidence": "Weak", - "note": "" - }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -117,7 +86,7 @@ "type": "controller", "class": "Alchemy::Admin::ElementsController", "method": "fold", - "line": 94, + "line": 97, "file": "app/controllers/alchemy/admin/elements_controller.rb", "rendered": { "name": "alchemy/admin/elements/fold", @@ -140,7 +109,7 @@ "check_name": "MassAssignment", "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", "file": "app/controllers/alchemy/admin/elements_controller.rb", - "line": 145, + "line": 150, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.fetch(:contents, {}).permit!", "render_path": null, @@ -284,39 +253,8 @@ "user_input": "params[:id]", "confidence": "Weak", "note": "" - }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "b9f63fd46d0ebd6684b649ab260f27df8a6422d44fed4769273d8e6a6a30397c", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/alchemy/admin/essence_files/assign.js.erb", - "line": 1, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => Alchemy::ContentEditor.new(Content.find_by(:id => params[:content_id])), {})", - "render_path": [ - { - "type": "controller", - "class": "Alchemy::Admin::EssenceFilesController", - "method": "assign", - "line": 32, - "file": "app/controllers/alchemy/admin/essence_files_controller.rb", - "rendered": { - "name": "alchemy/admin/essence_files/assign", - "file": "app/views/alchemy/admin/essence_files/assign.js.erb" - } - } - ], - "location": { - "type": "template", - "template": "alchemy/admin/essence_files/assign" - }, - "user_input": "params[:content_id]", - "confidence": "Weak", - "note": "" } ], - "updated": "2021-02-15 11:47:56 +0100", - "brakeman_version": "5.0.0" + "updated": "2021-06-29 20:56:10 +0200", + "brakeman_version": "5.0.1" } diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index c730461421..2aac564173 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -89,6 +89,11 @@ en: right: 'Right from text' no_float: 'Above the text' + ingredient_values: + boolean: + true: "True" + false: "False" + # == Contactform translations contactform: labels: @@ -307,6 +312,7 @@ en: "Warning!": "Warning!" content_definition_missing: "Warning: Content is missing its definition. Please check the elements.yml" content_deprecated: "WARNING! This content is deprecated and will be removed soon. Please do not use it anymore." + ingredient_deprecated: "WARNING! This content is deprecated and will be removed soon. Please do not use it anymore." element_definition_missing: "WARNING! Missing element definition. Please check your elements.yml file." element_deprecated: "WARNING! This element is deprecated and will be removed soon. Please do not use it anymore." page_definition_missing: "WARNING! Missing page layout definition. Please check your page_layouts.yml file." diff --git a/config/routes.rb b/config/routes.rb index 43500c4634..bd5c4e3e35 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,8 @@ resources :essence_videos, only: [:edit, :update] + resources :ingredients, only: [:edit, :update], concerns: [:croppable] + resources :legacy_page_urls resources :languages do collection do diff --git a/db/migrate/20210508091432_create_alchemy_ingredients.rb b/db/migrate/20210508091432_create_alchemy_ingredients.rb new file mode 100644 index 0000000000..5e7e392dcd --- /dev/null +++ b/db/migrate/20210508091432_create_alchemy_ingredients.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateAlchemyIngredients < ActiveRecord::Migration[6.0] + def change + create_table :alchemy_ingredients do |t| + t.references :element, null: false, foreign_key: { to_table: :alchemy_elements, on_delete: :cascade } + t.string :type, index: true, null: false + t.string :role, null: false + t.text :value + if ActiveRecord::Migration.connection.adapter_name.match?(/postgres/i) + t.jsonb :data, default: {} + else + t.json :data + end + t.belongs_to :related_object, null: true, polymorphic: true, index: false + t.index [:element_id, :role], unique: true + t.index [:related_object_id, :related_object_type], name: "idx_alchemy_ingredient_relation" + + t.timestamps + end + end +end diff --git a/lib/alchemy/hints.rb b/lib/alchemy/hints.rb index e767626bc2..d548c4e0ff 100644 --- a/lib/alchemy/hints.rb +++ b/lib/alchemy/hints.rb @@ -35,21 +35,25 @@ module Hints # @return String # def hint - hint = definition["hint"] + hint = definition[:hint] if hint == true - Alchemy.t(name, scope: hint_translation_scope) + Alchemy.t(hint_translation_attribute, scope: hint_translation_scope) else hint end end - # Returns true if the element has a hint + # Returns true if the element has a hint defined def has_hint? - hint.present? + !!definition[:hint] end private + def hint_translation_attribute + name + end + def hint_translation_scope "#{self.class.model_name.to_s.demodulize.downcase}_hints" end diff --git a/lib/alchemy/permissions.rb b/lib/alchemy/permissions.rb index 6b641a7cec..ac0b508fd3 100644 --- a/lib/alchemy/permissions.rb +++ b/lib/alchemy/permissions.rb @@ -115,6 +115,8 @@ def alchemy_author_rules can :manage, Alchemy::EssenceFile can :manage, Alchemy::EssencePicture can :manage, Alchemy::EssenceVideo + can :manage, Alchemy::Ingredient + can [:crop], Alchemy::Ingredients::Picture can :manage, Alchemy::LegacyPageUrl can :manage, Alchemy::Node can [:read, :url], Alchemy::Picture diff --git a/lib/alchemy/test_support/factories/element_factory.rb b/lib/alchemy/test_support/factories/element_factory.rb index b0dbaafaf8..7db7670c46 100644 --- a/lib/alchemy/test_support/factories/element_factory.rb +++ b/lib/alchemy/test_support/factories/element_factory.rb @@ -4,6 +4,7 @@ factory :alchemy_element, class: "Alchemy::Element" do name { "article" } autogenerate_contents { false } + autogenerate_ingredients { false } association :page_version, factory: :alchemy_page_version trait :fixed do @@ -28,5 +29,10 @@ trait :with_contents do autogenerate_contents { true } end + + trait :with_ingredients do + name { "element_with_ingredients" } + autogenerate_ingredients { true } + end end end diff --git a/lib/alchemy/test_support/factories/ingredient_factory.rb b/lib/alchemy/test_support/factories/ingredient_factory.rb new file mode 100644 index 0000000000..907e8bcffa --- /dev/null +++ b/lib/alchemy/test_support/factories/ingredient_factory.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +FactoryBot.define do + %w[ + audio + boolean + datetime + file + headline + html + link + node + page + picture + richtext + select + text + ].each do |ingredient| + factory :"alchemy_ingredient_#{ingredient}", class: "Alchemy::Ingredients::#{ingredient.classify}" do + role { ingredient } + type { "Alchemy::Ingredients::#{ingredient.classify}" } + association :element, name: "all_you_can_eat_ingredients", factory: :alchemy_element + end + end +end diff --git a/lib/alchemy/test_support/shared_ingredient_editor_examples.rb b/lib/alchemy/test_support/shared_ingredient_editor_examples.rb new file mode 100644 index 0000000000..08552b32b1 --- /dev/null +++ b/lib/alchemy/test_support/shared_ingredient_editor_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for "an alchemy ingredient editor" do + let(:ingredient_editor) { Alchemy::IngredientEditor.new(ingredient) } + + before do + view.class.send :include, Alchemy::Admin::BaseHelper + view.class.send :include, Alchemy::Admin::IngredientsHelper + allow(element_editor).to receive(:ingredients) { [ingredient_editor] } + end + + subject do + render element_editor + rendered + end + + it "renders a ingredient editor", :aggregate_failures do + is_expected.to have_css(".ingredient-editor.#{ingredient_editor.partial_name}") + is_expected.to have_css("[data-ingredient-role]") + end +end diff --git a/lib/alchemy/test_support/shared_ingredient_examples.rb b/lib/alchemy/test_support/shared_ingredient_examples.rb new file mode 100644 index 0000000000..78a9aceae9 --- /dev/null +++ b/lib/alchemy/test_support/shared_ingredient_examples.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "shoulda-matchers" + +RSpec.shared_examples_for "an alchemy ingredient" do + let(:element) { build(:alchemy_element, name: "element_with_ingredients") } + + subject(:ingredient) do + described_class.new( + element: element, + role: "headline", + ) + end + + it { is_expected.to belong_to(:element).touch(true).class_name("Alchemy::Element") } + it { is_expected.to belong_to(:related_object).optional } + it { is_expected.to validate_presence_of(:role) } + it { is_expected.to validate_presence_of(:type) } + it { expect(subject.data).to eq({}) } + + describe "#settings" do + subject { ingredient.settings } + + context "without element" do + let(:element) { nil } + + it { is_expected.to eq({}) } + end + + context "with element" do + it { is_expected.to eq({ linkable: true }.with_indifferent_access) } + end + end + + describe "#definition" do + subject { ingredient.definition } + + context "without element" do + let(:element) { nil } + + it { is_expected.to eq({}) } + end + + context "with element" do + it do + is_expected.to eq({ + role: "headline", + type: "Text", + default: "Hello World", + settings: { + linkable: true, + }, + }.with_indifferent_access) + end + end + end +end diff --git a/lib/alchemy/tinymce.rb b/lib/alchemy/tinymce.rb index 27f3862187..156bc4d5a3 100644 --- a/lib/alchemy/tinymce.rb +++ b/lib/alchemy/tinymce.rb @@ -38,6 +38,10 @@ def custom_config_contents(page) content_definitions_from_elements(page.descendent_element_definitions) end + def custom_config_ingredients(page) + ingredient_definitions_from_elements(page.descendent_element_definitions) + end + private def content_definitions_from_elements(definitions) @@ -52,6 +56,19 @@ def content_definitions_from_elements(definitions) contents.map { |c| c.merge("element" => el["name"]) } end.flatten.compact end + + def ingredient_definitions_from_elements(definitions) + definitions.collect do |el| + next if el["ingredients"].blank? + + ingredients = el["ingredients"].select do |c| + c["settings"] && c["settings"]["tinymce"].is_a?(Hash) + end + next if ingredients.blank? + + ingredients.map { |c| c.merge("element" => el["name"]) } + end.flatten.compact + end end end end diff --git a/lib/alchemy/upgrader/six_point_zero.rb b/lib/alchemy/upgrader/six_point_zero.rb index 0a4900273e..e8384615ce 100644 --- a/lib/alchemy/upgrader/six_point_zero.rb +++ b/lib/alchemy/upgrader/six_point_zero.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "tasks/add_page_versions" +require_relative "tasks/ingredients_migrator" module Alchemy class Upgrader::SixPointZero < Upgrader @@ -9,6 +10,12 @@ def create_public_page_versions desc "Create public page versions for pages" Alchemy::Upgrader::Tasks::AddPageVersions.new.create_public_page_versions end + + def create_ingredients + desc "Create ingredients for elements with ingredients defined" + Alchemy::Upgrader::Tasks::IngredientsMigrator.new.create_ingredients + log "Done.", :success + end end end end diff --git a/lib/alchemy/upgrader/tasks/ingredients_migrator.rb b/lib/alchemy/upgrader/tasks/ingredients_migrator.rb new file mode 100644 index 0000000000..a4f1255639 --- /dev/null +++ b/lib/alchemy/upgrader/tasks/ingredients_migrator.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "alchemy/upgrader" + +module Alchemy::Upgrader::Tasks + class IngredientsMigrator < Thor + include Thor::Actions + + no_tasks do + def create_ingredients + Alchemy::Deprecation.silence do + elements_with_ingredients = Alchemy::ElementDefinition.all.select { |d| d.key?(:ingredients) } + # eager load all elements that have ingredients defined but no ingredient records yet. + all_elements = Alchemy::Element + .named(elements_with_ingredients.map { |d| d[:name] }) + .includes(contents: { essence: :ingredient_association }) + .left_outer_joins(:ingredients).where(alchemy_ingredients: { id: nil }) + .to_a + elements_with_ingredients.map do |element_definition| + elements = all_elements.select { |e| e.name == element_definition[:name] } + if elements.any? + puts "-- Creating ingredients for #{elements.count} #{element_definition[:name]}(s)" + elements.each do |element| + Alchemy::Element.transaction do + element.ingredients = element_definition[:ingredients].map do |ingredient_definition| + content = element.content_by_name(ingredient_definition[:role]) + next unless content + + ingredient = Alchemy::Ingredient.build(role: ingredient_definition[:role], element: element) + belongs_to_associations = content.essence.class.reflect_on_all_associations(:belongs_to) + if belongs_to_associations.any? + ingredient.related_object = content.essence.public_send(belongs_to_associations.first.name) + else + ingredient.value = content.ingredient + end + content.destroy! + print "." + ingredient + end.compact + end + end + puts "\n" + else + puts "-- No #{element_definition[:name]} elements found for migration." + end + end + end + end + end + end +end diff --git a/lib/generators/alchemy/elements/elements_generator.rb b/lib/generators/alchemy/elements/elements_generator.rb index 966dc85af6..d2123ab497 100644 --- a/lib/generators/alchemy/elements/elements_generator.rb +++ b/lib/generators/alchemy/elements/elements_generator.rb @@ -14,6 +14,7 @@ def create_partials @elements.each do |element| @element = element @contents = element["contents"] || [] + @ingredients = element["ingredients"] || [] @element_name = element_name(element) conditional_template "view.html.#{template_engine}", "#{elements_dir}/_#{@element_name}.html.#{template_engine}" end diff --git a/lib/generators/alchemy/elements/templates/view.html.erb b/lib/generators/alchemy/elements/templates/view.html.erb index db23ac69b6..9b6c681c4b 100644 --- a/lib/generators/alchemy/elements/templates/view.html.erb +++ b/lib/generators/alchemy/elements/templates/view.html.erb @@ -9,6 +9,15 @@ <%%= el.render :<%= content["name"] %> %> <%- end -%> <%- end -%> + <%- @ingredients.each do |ingredient| -%> + <%- if @ingredients.length > 1 -%> +
    "> + <%%= el.render(:<%= ingredient["role"] %>) %> +
    + <%- else -%> + <%%= el.render(:<%= ingredient["role"] %>) %> + <%- end -%> + <%- end -%> <%- if @element['nestable_elements'].present? -%> <%%= render <%= @element_name %>.nested_elements.available %> <%- end -%> diff --git a/lib/generators/alchemy/elements/templates/view.html.haml b/lib/generators/alchemy/elements/templates/view.html.haml index 01d6506078..3c5c7dccc1 100644 --- a/lib/generators/alchemy/elements/templates/view.html.haml +++ b/lib/generators/alchemy/elements/templates/view.html.haml @@ -8,6 +8,15 @@ = el.render :<%= content["name"] %> <%- end -%> <%- end -%> + <%- @ingredients.each do |ingredient| -%> + <%- if @ingredients.length > 1 -%> + .<%= ingredient["role"] %> + = el.render(:<%= ingredient["role"] %>) +
  • + <%- else -%> + = el.render(:<%= ingredient["role"] %>) + <%- end -%> + <%- end -%> <%- if @element['nestable_elements'].present? -%> = render <%= @element_name -%>.nested_elements.available <%- end -%> diff --git a/lib/generators/alchemy/elements/templates/view.html.slim b/lib/generators/alchemy/elements/templates/view.html.slim index 01d6506078..3c5c7dccc1 100644 --- a/lib/generators/alchemy/elements/templates/view.html.slim +++ b/lib/generators/alchemy/elements/templates/view.html.slim @@ -8,6 +8,15 @@ = el.render :<%= content["name"] %> <%- end -%> <%- end -%> + <%- @ingredients.each do |ingredient| -%> + <%- if @ingredients.length > 1 -%> + .<%= ingredient["role"] %> + = el.render(:<%= ingredient["role"] %>) + + <%- else -%> + = el.render(:<%= ingredient["role"] %>) + <%- end -%> + <%- end -%> <%- if @element['nestable_elements'].present? -%> = render <%= @element_name -%>.nested_elements.available <%- end -%> diff --git a/lib/generators/alchemy/ingredient/ingredient_generator.rb b/lib/generators/alchemy/ingredient/ingredient_generator.rb new file mode 100644 index 0000000000..82afa0daac --- /dev/null +++ b/lib/generators/alchemy/ingredient/ingredient_generator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require "rails" + +module Alchemy + module Generators + class IngredientGenerator < ::Rails::Generators::Base + desc "This generator generates an Alchemy ingredient class for you." + argument :class_name, banner: "ingredient_class_name" + source_root File.expand_path("templates", __dir__) + + def init + @class_name = class_name.classify + @ingredients_view_path = "app/views/alchemy/ingredients" + end + + def create_model + template "model.rb.tt", "app/models/alchemy/ingredients/#{file_name}.rb" + end + + def copy_templates + @ingredient_editor_local = "#{file_name}_editor" + @ingredient_view_local = "#{file_name}_view" + template "view.html.erb", "#{@ingredients_view_path}/_#{file_name}_view.html.erb" + template "editor.html.erb", "#{@ingredients_view_path}/_#{file_name}_editor.html.erb" + end + + def show_todo + say "\nPlease check the generated files and alter them to fit your needs." + end + + private + + def file_name + @_file_name ||= @class_name.classify.demodulize.underscore + end + end + end +end diff --git a/lib/generators/alchemy/ingredient/templates/editor.html.erb b/lib/generators/alchemy/ingredient/templates/editor.html.erb new file mode 100644 index 0000000000..4b69a830c7 --- /dev/null +++ b/lib/generators/alchemy/ingredient/templates/editor.html.erb @@ -0,0 +1,14 @@ +<%%# + Available locals: + * <%= @ingredient_editor_local %> - An Alchemy::IngredientEditor instance + + Please consult Alchemy::IngredientEditor.rb docs for further methods on the ingredient object +%> +<%%= content_tag :div, + class: <%= @ingredient_editor_local %>.css_classes, + data: <%= @ingredient_editor_local %>.data_attributes do %> + <%%= element_form.fields_for(:ingredients, <%= @ingredient_editor_local %>.ingredient) do |f| %> + <%%= ingredient_label(<%= @ingredient_editor_local %>) %> + <%%= f.text_field :value %> + <%% end %> +<%% end %> diff --git a/lib/generators/alchemy/ingredient/templates/model.rb.tt b/lib/generators/alchemy/ingredient/templates/model.rb.tt new file mode 100644 index 0000000000..fda21a74ec --- /dev/null +++ b/lib/generators/alchemy/ingredient/templates/model.rb.tt @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Alchemy + module Ingredients + class <%= @class_name %> < Alchemy::Ingredient + # Set additional attributes that get stored in the `data` JSON column + # store_accessor :data, :some, :attribute + + # Set a related_object alias for convenience + # related_object_alias :some_association_name, class_name: "Some::Klass" + end + end +end diff --git a/lib/generators/alchemy/ingredient/templates/view.html.erb b/lib/generators/alchemy/ingredient/templates/view.html.erb new file mode 100644 index 0000000000..8d942fd7d8 --- /dev/null +++ b/lib/generators/alchemy/ingredient/templates/view.html.erb @@ -0,0 +1 @@ +<%%= <%= @ingredient_view_local %>.value %> diff --git a/lib/tasks/alchemy/upgrade.rake b/lib/tasks/alchemy/upgrade.rake index 6283acd79c..61e21fccd7 100644 --- a/lib/tasks/alchemy/upgrade.rake +++ b/lib/tasks/alchemy/upgrade.rake @@ -72,12 +72,18 @@ namespace :alchemy do namespace "6.0" do task "run" => [ "alchemy:upgrade:6.0:create_public_page_versions", + "alchemy:upgrade:6.0:create_ingredients", ] - desc "Install Gutentag migrations" + desc "Create public page versions" task create_public_page_versions: [:environment] do Alchemy::Upgrader::SixPointZero.create_public_page_versions end + + desc "Create ingredients for elements with ingredients defined" + task create_ingredients: [:environment] do + Alchemy::Upgrader::SixPointZero.create_ingredients + end end end end diff --git a/spec/controllers/alchemy/admin/elements_controller_spec.rb b/spec/controllers/alchemy/admin/elements_controller_spec.rb index edeef7bde0..44832be29e 100644 --- a/spec/controllers/alchemy/admin/elements_controller_spec.rb +++ b/spec/controllers/alchemy/admin/elements_controller_spec.rb @@ -6,18 +6,18 @@ module Alchemy describe Admin::ElementsController do routes { Alchemy::Engine.routes } - let(:page_version) { create(:alchemy_page_version) } - let(:element) { create(:alchemy_element, page_version: page_version) } + let(:page_version) { create(:alchemy_page_version) } + let(:element) { create(:alchemy_element, page_version: page_version) } let(:element_in_clipboard) { create(:alchemy_element, page_version: page_version) } - let(:clipboard) { session[:alchemy_clipboard] = {} } + let(:clipboard) { session[:alchemy_clipboard] = {} } before { authorize_user(:as_author) } describe "#index" do - let!(:page_version) { create(:alchemy_page_version) } - let!(:element) { create(:alchemy_element, page_version: page_version) } - let!(:nested_element) { create(:alchemy_element, :nested, page_version: page_version) } - let!(:hidden_element) { create(:alchemy_element, page_version: page_version, public: false) } + let!(:page_version) { create(:alchemy_page_version) } + let!(:element) { create(:alchemy_element, page_version: page_version) } + let!(:nested_element) { create(:alchemy_element, :nested, page_version: page_version) } + let!(:hidden_element) { create(:alchemy_element, page_version: page_version, public: false) } context "with fixed elements" do let!(:fixed_element) do @@ -41,9 +41,9 @@ module Alchemy end describe "#order" do - let!(:element_1) { create(:alchemy_element) } - let!(:element_2) { create(:alchemy_element, page_version: page_version) } - let!(:element_3) { create(:alchemy_element, page_version: page_version) } + let!(:element_1) { create(:alchemy_element) } + let!(:element_2) { create(:alchemy_element, page_version: page_version) } + let!(:element_3) { create(:alchemy_element, page_version: page_version) } let(:element_ids) { [element_1.id, element_3.id, element_2.id] } let(:page_version) { element_1.page_version } @@ -69,17 +69,17 @@ module Alchemy parent.update_column(:updated_at, 3.days.ago) expect { post :order, params: { - element_ids: element_ids, - parent_element_id: parent.id, - }, xhr: true + element_ids: element_ids, + parent_element_id: parent.id, + }, xhr: true }.to change { parent.reload.updated_at } end it "assigns parent element id to each element" do post :order, params: { - element_ids: element_ids, - parent_element_id: parent.id, - }, xhr: true + element_ids: element_ids, + parent_element_id: parent.id, + }, xhr: true [element_1, element_2, element_3].each do |element| expect(element.reload.parent_element_id).to eq parent.id end @@ -92,18 +92,18 @@ module Alchemy it "assign variable for all available element definitions" do expect_any_instance_of(Alchemy::Page).to receive(:available_element_definitions) - get :new, params: {page_version_id: page_version.id} + get :new, params: { page_version_id: page_version.id } end context "with elements in clipboard" do let(:element) { create(:alchemy_element, page_version: page_version) } - let(:clipboard_items) { [{"id" => element.id.to_s, "action" => "copy"}] } + let(:clipboard_items) { [{ "id" => element.id.to_s, "action" => "copy" }] } before { clipboard["elements"] = clipboard_items } it "should load all elements from clipboard" do expect(Element).to receive(:all_from_clipboard_for_page).and_return(clipboard_items) - get :new, params: {page_version_id: page_version.id} + get :new, params: { page_version_id: page_version.id } expect(assigns(:clipboard_items)).to eq(clipboard_items) end end @@ -151,7 +151,7 @@ module Alchemy render_views before do - clipboard["elements"] = [{"id" => element_in_clipboard.id.to_s, "action" => "cut"}] + clipboard["elements"] = [{ "id" => element_in_clipboard.id.to_s, "action" => "cut" }] end it "should create an element from clipboard" do @@ -192,97 +192,77 @@ module Alchemy end describe "#update" do - let(:element) { build_stubbed(:alchemy_element) } - let(:contents_parameters) { ActionController::Parameters.new(1 => {ingredient: "Title"}) } - let(:element_parameters) { ActionController::Parameters.new(tag_list: "Tag 1", public: false) } - before do - expect(Element).to receive(:find).and_return element - expect(controller).to receive(:contents_params).and_return(contents_parameters) - end - - it "updates all contents in element" do - expect(element).to receive(:update_contents).with(contents_parameters) - put :update, params: {id: element.id}, xhr: true + expect(Element).to receive(:find).at_least(:once).and_return(element) end - it "updates the element" do - expect(controller).to receive(:element_params).and_return(element_parameters) - expect(element).to receive(:update_contents).and_return(true) - expect(element).to receive(:update).with(element_parameters).and_return(true) - put :update, params: {id: element.id}, xhr: true - end - - context "failed validations" do - it "displays validation failed notice" do - expect(element).to receive(:update_contents).and_return(false) - put :update, params: {id: element.id}, xhr: true - expect(assigns(:element_validated)).to be_falsey + context "with element having contents" do + subject do + put :update, params: { id: element.id, element: element_params, contents: contents_params }, xhr: true end - end - end - describe "#destroy" do - subject { delete :destroy, params: { id: element.id }, xhr: true } - - let!(:element) { create(:alchemy_element) } + let(:element) { create(:alchemy_element, :with_contents) } + let(:content) { element.contents.first } + let(:element_params) { { tag_list: "Tag 1", public: false } } + let(:contents_params) { { content.id => { ingredient: "Title" } } } - it "deletes the element" do - expect { subject }.to change(Alchemy::Element, :count).to(0) - end - end - - describe "params security" do - context "contents params" do - let(:parameters) { ActionController::Parameters.new(contents: {1 => {ingredient: "Title"}}) } + it "updates all contents in element" do + expect { subject }.to change { content.reload.ingredient }.to("Title") + end - specify ":contents is required" do - expect(controller.params).to receive(:fetch).and_return(parameters) - controller.send :contents_params + it "updates the element" do + expect { subject }.to change { element.tag_list }.to(["Tag 1"]) end - specify "everything is permitted" do - expect(controller).to receive(:params).and_return(parameters) - expect(parameters).to receive(:fetch).and_return(parameters) - expect(parameters).to receive(:permit!) - controller.send :contents_params + context "failed validations" do + it "displays validation failed notice" do + expect(element).to receive(:update_contents).and_return(false) + subject + expect(assigns(:element_validated)).to be_falsey + end end end - context "element params" do - let(:parameters) { ActionController::Parameters.new(element: {public: true}) } - - before do - expect(controller).to receive(:params).and_return(parameters) - expect(parameters).to receive(:fetch).with(:element, {}).and_return(parameters) + context "with element having ingredients" do + subject do + put :update, params: { id: element.id, element: element_params }, xhr: true end - context "with taggable element" do - before do - controller.instance_variable_set(:'@element', mock_model(Element, taggable?: true)) - end + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + let(:ingredients_attributes) { { 0 => { id: ingredient.id, value: "Title" } } } + let(:element_params) { { tag_list: "Tag 1", public: false, ingredients_attributes: ingredients_attributes } } - specify ":tag_list is permitted" do - expect(parameters).to receive(:permit).with(:tag_list) - controller.send :element_params - end + it "updates all ingredients in element" do + expect { subject }.to change { ingredient.value }.to("Title") end - context "with not taggable element" do - before do - controller.instance_variable_set(:'@element', mock_model(Element, taggable?: false)) - end + it "updates the element" do + expect { subject }.to change { element.tag_list }.to(["Tag 1"]) + end - specify ":tag_list is not permitted" do - expect(parameters).to_not receive(:permit) - controller.send :element_params + context "failed validations" do + it "displays validation failed notice" do + expect(element).to receive(:update).and_return(false) + subject + expect(assigns(:element_validated)).to be_falsey end end end end + describe "#destroy" do + subject { delete :destroy, params: { id: element.id }, xhr: true } + + let!(:element) { create(:alchemy_element) } + + it "deletes the element" do + expect { subject }.to change(Alchemy::Element, :count).to(0) + end + end + describe "#fold" do - subject { post :fold, params: {id: element.id}, xhr: true } + subject { post :fold, params: { id: element.id }, xhr: true } let(:element) { build_stubbed(:alchemy_element) } diff --git a/spec/controllers/alchemy/admin/ingredients_controller_spec.rb b/spec/controllers/alchemy/admin/ingredients_controller_spec.rb new file mode 100644 index 0000000000..bebe87a40d --- /dev/null +++ b/spec/controllers/alchemy/admin/ingredients_controller_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Admin::IngredientsController do + routes { Alchemy::Engine.routes } + + let(:attachment) { build_stubbed(:alchemy_attachment) } + let(:element) { build(:alchemy_element, name: "all_you_can_eat_ingredients") } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::File, + type: "Alchemy::Ingredients::File", + element: element, + attachment: attachment, + role: "file", + ) + end + + before do + allow(Alchemy::Ingredient).to receive(:find).with(ingredient.id.to_s) { ingredient } + end + + context "without authorized user" do + describe "get :edit" do + it "redirects to login path" do + get :edit, params: { id: ingredient.id } + expect(response).to redirect_to(Alchemy.login_path) + end + end + + describe "patch :update" do + it "redirects to login path" do + patch :update, params: { id: ingredient.id } + expect(response).to redirect_to(Alchemy.login_path) + end + end + end + + context "with autorized user" do + before do + authorize_user(:as_admin) + end + + describe "get :edit" do + subject { get(:edit, params: { id: ingredient.id }) } + + it "assigns @ingredient with the Ingredient found by id" do + subject + expect(assigns(:ingredient)).to eq(ingredient) + end + + it "renders edit template" do + expect(subject).to render_template("alchemy/admin/ingredients/edit") + end + end + + describe "patch :update" do + context "with permitted attributes" do + let(:params) do + { + id: ingredient.id, + ingredient: { + title: "new title", + css_class: "left", + link_text: "Download this file", + }, + } + end + + it "updates the attributes of ingredient" do + patch :update, params: params, xhr: true + expect(ingredient.title).to eq "new title" + expect(ingredient.css_class).to eq "left" + expect(ingredient.link_text).to eq "Download this file" + end + end + + context "with unpermitted attributes" do + let(:params) do + { + id: ingredient.id, + ingredient: { + foo: "Baz", + }, + } + end + + it "does not update the attributes of ingredient" do + expect(ingredient).to receive(:update).with({}) + patch :update, params: params, xhr: true + end + end + end + + it_behaves_like "having crop action", model_class: Alchemy::Ingredient do + let(:croppable_resource) do + Alchemy::Ingredient.build( + type: "Alchemy::Ingredients::Picture", + element: element, + attachment: attachment, + role: "picture", + ) + end + end + end +end diff --git a/spec/decorators/alchemy/element_editor_spec.rb b/spec/decorators/alchemy/element_editor_spec.rb index bca04dc838..a23cd85143 100644 --- a/spec/decorators/alchemy/element_editor_spec.rb +++ b/spec/decorators/alchemy/element_editor_spec.rb @@ -178,6 +178,18 @@ it { is_expected.to eq(false) } end + + context "but element has ingredients defined" do + before { + expect(element).to receive(:ingredient_definitions) { + [{ + role: "headline", type: "Headline", + }] + } + } + + it { is_expected.to eq(true) } + end end end end diff --git a/spec/decorators/alchemy/ingredient_editor_spec.rb b/spec/decorators/alchemy/ingredient_editor_spec.rb new file mode 100644 index 0000000000..d773e21068 --- /dev/null +++ b/spec/decorators/alchemy/ingredient_editor_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::IngredientEditor do + let(:element) { build(:alchemy_element, name: "element_with_ingredients") } + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + let(:ingredient_editor) { described_class.new(ingredient) } + + describe "#ingredient" do + it "returns ingredient object" do + expect(ingredient_editor.ingredient).to be(ingredient) + end + end + + describe "#css_classes" do + subject { ingredient_editor.css_classes } + + it "includes ingredient_editor class" do + is_expected.to include("ingredient-editor") + end + + it "includes essence partial class" do + is_expected.to include(ingredient.partial_name) + end + + context "when deprecated" do + before do + expect(ingredient).to receive(:deprecated?) { true } + end + + it "includes deprecated" do + is_expected.to include("deprecated") + end + end + end + + describe "#data_attributes" do + it "includes ingredient_id" do + expect(ingredient_editor.data_attributes[:ingredient_id]).to eq(ingredient.id) + end + + it "includes ingredient_role" do + expect(ingredient_editor.data_attributes[:ingredient_role]).to eq(ingredient.role) + end + end + + describe "#to_partial_path" do + subject { ingredient_editor.to_partial_path } + + it "returns the editor partial path" do + is_expected.to eq("alchemy/ingredients/text_editor") + end + end + + describe "#form_field_name" do + it "returns a name for form fields with value as default" do + expect(ingredient_editor.form_field_name).to eq("element[ingredients_attributes][0][value]") + end + + context "with a value given" do + it "returns a name for form fields for that column" do + expect(ingredient_editor.form_field_name(:link_title)).to eq("element[ingredients_attributes][0][link_title]") + end + end + end + + describe "#form_field_id" do + it "returns a id value for form fields with ingredient as default" do + expect(ingredient_editor.form_field_id).to eq("element_ingredients_attributes_0_value") + end + + context "with a value given" do + it "returns a id value for form fields for that column" do + expect(ingredient_editor.form_field_id(:link_title)).to eq("element_ingredients_attributes_0_link_title") + end + end + end + + describe "#respond_to?(:to_model)" do + subject { ingredient_editor.respond_to?(:to_model) } + + it { is_expected.to be(false) } + end + + describe "#has_warnings?" do + subject { ingredient_editor.has_warnings? } + + context "when ingredient is not deprecated" do + it { is_expected.to be(false) } + end + + context "when ingredient is deprecated" do + let(:ingredient) do + mock_model("Alchemy::Ingredients::Text", definition: { deprecated: true }, deprecated?: true) + end + + it { is_expected.to be(true) } + end + + context "when ingredient is missing its definition" do + let(:ingredient) do + mock_model("Alchemy::Ingredients::Text", definition: {}) + end + + it { is_expected.to be(true) } + end + end + + describe "#warnings" do + subject { ingredient_editor.warnings } + + context "when ingredient has no warnings" do + it { is_expected.to be_nil } + end + + context "when ingredient is missing its definition" do + let(:ingredient) do + mock_model("Alchemy::Ingredients::Text", definition: {}) + end + + it { is_expected.to eq Alchemy.t(:ingredient_definition_missing) } + + it "logs a warning" do + expect(Alchemy::Logger).to receive(:warn) + subject + end + end + + context "when ingredient is deprecated" do + let(:ingredient) do + mock_model( + "Alchemy::Ingredients::Text", + definition: { + role: "foo", + deprecated: "Deprecated", + }, deprecated?: true, + ) + end + + it "returns a deprecation notice" do + is_expected.to eq("Deprecated") + end + end + end + + describe "#deprecation_notice" do + subject { ingredient_editor.deprecation_notice } + + context "when ingredient is not deprecated" do + it { is_expected.to be_nil } + end + + context "when ingredient is deprecated" do + context "with String as deprecation" do + let(:ingredient) do + mock_model( + "Alchemy::Ingredients::Text", + definition: { + role: "foo", + deprecated: "Ingredient is deprecated", + }, deprecated?: true, + ) + end + + it { is_expected.to eq("Ingredient is deprecated") } + end + + context "without custom ingredient translation" do + let(:ingredient) do + mock_model( + "Alchemy::Ingredients::Text", + definition: { + role: "foo", + deprecated: true, + }, deprecated?: true, + element: element, + ) + end + + it do + is_expected.to eq( + "WARNING! This content is deprecated and will be removed soon. " \ + "Please do not use it anymore." + ) + end + end + + context "with custom ingredient translation" do + let(:element) { build(:alchemy_element, name: "all_you_can_eat_ingredients") } + + let(:ingredient) do + Alchemy::Ingredients::Html.build( + role: "html", + element: element, + ) + end + + it { is_expected.to eq("Old ingredient is deprecated") } + end + end + end +end diff --git a/spec/dummy/app/views/alchemy/elements/_all_you_can_eat_ingredients.html.erb b/spec/dummy/app/views/alchemy/elements/_all_you_can_eat_ingredients.html.erb new file mode 100644 index 0000000000..23bb9b9070 --- /dev/null +++ b/spec/dummy/app/views/alchemy/elements/_all_you_can_eat_ingredients.html.erb @@ -0,0 +1,46 @@ +<%- cache(all_you_can_eat_ingredients) do -%> + <%= element_view_for(all_you_can_eat_ingredients) do |el| -%> +
    + <%= el.render(:headline) %> +
    +
    + <%= el.render(:text) %> +
    +
    + <%= el.render(:picture) %> +
    +
    + <%= el.render(:richtext) %> +
    +
    + <%= el.render(:select) %> +
    +
    + <%= el.render(:boolean) %> +
    +
    + <%= el.render(:datetime) %> +
    +
    + <%= el.render(:file) %> +
    +
    + <%= el.render(:html) %> +
    + +
    + <%= el.render(:node) %> +
    +
    + <%= el.render(:audio) %> +
    +
    + <%= el.render(:video) %> +
    +
    + <%= el.render(:page) %> +
    + <%- end -%> +<%- end -%> diff --git a/spec/dummy/app/views/alchemy/elements/_element_with_ingredients.html.erb b/spec/dummy/app/views/alchemy/elements/_element_with_ingredients.html.erb new file mode 100644 index 0000000000..d1e75a5979 --- /dev/null +++ b/spec/dummy/app/views/alchemy/elements/_element_with_ingredients.html.erb @@ -0,0 +1,10 @@ +<%- cache(element_with_ingredients) do -%> + <%= element_view_for(element_with_ingredients) do |el| -%> +
    + <%= el.render(:headline) %> +
    +
    + <%= el.render(:text) %> +
    + <%- end -%> +<%- end -%> diff --git a/spec/dummy/config/alchemy/elements.yml b/spec/dummy/config/alchemy/elements.yml index e78b96b490..e7184b126d 100644 --- a/spec/dummy/config/alchemy/elements.yml +++ b/spec/dummy/config/alchemy/elements.yml @@ -124,6 +124,68 @@ type: EssenceLink hint: true +- name: all_you_can_eat_ingredients + hint: true + taggable: true + ingredients: + - role: headline + type: Headline + hint: true + validate: + - presence + - role: text + type: Text + hint: true + validate: + - format: !ruby/regexp '/\w+/i' + - role: picture + type: Picture + hint: true + settings: + size: 1200x480 + crop: true + - role: richtext + type: Richtext + hint: true + settings: + tinymce: + toolbar: bold italic underline | undo redo | pastetext alchemy_link unlink | fullscreen code + - role: select + type: Select + hint: true + settings: + select_values: [A, B, C] + validate: + - uniqueness + - role: boolean + type: Boolean + hint: true + - role: datetime + type: Datetime + hint: true + - role: file + type: File + hint: true + - role: html + type: Html + hint: true + deprecated: true + - role: link + type: Link + hint: true + - role: node + type: Node + hint: true + - role: audio + type: Audio + hint: true + - role: video + type: Video + hint: true + - role: page + type: Page + hint: true + - name: <%= 'erb_' + 'element' %> contents: - name: text @@ -187,3 +249,14 @@ type: EssenceText - name: text type: EssenceRichtext + +- name: element_with_ingredients + ingredients: + - role: headline + type: Text + default: Hello World + settings: + linkable: true + - role: text + type: Richtext + default: :lorem_ipsum diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index ba9739198c..957e6c478d 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -1,5 +1,9 @@ - name: index unique: true + elements: + - all_you_can_eat_ingredients + autogenerate: + - all_you_can_eat_ingredients - name: readonly fixed_attributes: @@ -19,7 +23,17 @@ autogenerate: [header, article, download] - name: everything - elements: [text, all_you_can_eat, gallery, right_column, left_column, old] + elements: + [ + text, + all_you_can_eat, + gallery, + right_column, + left_column, + old, + all_you_can_eat_ingredients, + element_with_ingredients + ] autogenerate: [all_you_can_eat, right_column, left_column] - name: news @@ -38,7 +52,7 @@ - name: footer elements: - - menu + - menu layoutpage: true - name: <%= 'erb_' + 'layout' %> diff --git a/spec/dummy/config/locales/alchemy.en.yml b/spec/dummy/config/locales/alchemy.en.yml index 6cc35f9b66..fb27c7ac22 100644 --- a/spec/dummy/config/locales/alchemy.en.yml +++ b/spec/dummy/config/locales/alchemy.en.yml @@ -26,14 +26,30 @@ en: essence_text: This content type (Essence) represents a simple line of text default_content_texts: welcome: Welcome to my site + default_ingredient_texts: + lorem_ipsum: Dapibus nostra massa phasellus viverra rhoncus fringilla resource_help_texts: party: name: Party content_deprecation_notices: all_you_can_eat: essence_html: Old content is deprecated + ingredient_deprecation_notices: + all_you_can_eat_ingredients: + html: Old ingredient is deprecated element_deprecation_notices: old: Old element is deprecated + ingredient_validations: + errors: + blank: "%{field} can't be blank" + invalid: "%{field} is invalid" + taken: "%{field} has already been taken" + fields: + select: + taken: "Please select something else" + all_you_can_eat_ingredients: + headline: + blank: Please enter a headline for all you can eat activemodel: models: diff --git a/spec/dummy/db/migrate/20210508091432_create_alchemy_ingredients.rb b/spec/dummy/db/migrate/20210508091432_create_alchemy_ingredients.rb new file mode 120000 index 0000000000..e94fcb1c52 --- /dev/null +++ b/spec/dummy/db/migrate/20210508091432_create_alchemy_ingredients.rb @@ -0,0 +1 @@ +../../../../db/migrate/20210508091432_create_alchemy_ingredients.rb \ No newline at end of file diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 5506a0e6cd..d4aab1aa76 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_05_06_140258) do +ActiveRecord::Schema.define(version: 2021_05_08_091432) do create_table "alchemy_attachments", force: :cascade do |t| t.string "name" @@ -177,6 +177,22 @@ t.index ["page_id", "user_id"], name: "index_alchemy_folded_pages_on_page_id_and_user_id", unique: true end + create_table "alchemy_ingredients", force: :cascade do |t| + t.integer "element_id", null: false + t.string "type", null: false + t.string "role", null: false + t.text "value" + t.json "data" + t.string "related_object_type" + t.integer "related_object_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["element_id", "role"], name: "index_alchemy_ingredients_on_element_id_and_role", unique: true + t.index ["element_id"], name: "index_alchemy_ingredients_on_element_id" + t.index ["related_object_id", "related_object_type"], name: "idx_alchemy_ingredient_relation" + t.index ["type"], name: "index_alchemy_ingredients_on_type" + end + create_table "alchemy_languages", force: :cascade do |t| t.string "name" t.string "language_code" @@ -384,6 +400,7 @@ add_foreign_key "alchemy_elements", "alchemy_page_versions", column: "page_version_id", on_delete: :cascade add_foreign_key "alchemy_essence_nodes", "alchemy_nodes", column: "node_id" add_foreign_key "alchemy_essence_pages", "alchemy_pages", column: "page_id" + add_foreign_key "alchemy_ingredients", "alchemy_elements", column: "element_id", on_delete: :cascade add_foreign_key "alchemy_nodes", "alchemy_languages", column: "language_id" add_foreign_key "alchemy_nodes", "alchemy_pages", column: "page_id", on_delete: :cascade add_foreign_key "alchemy_page_versions", "alchemy_pages", column: "page_id", on_delete: :cascade diff --git a/spec/helpers/alchemy/admin/ingredients_helper_spec.rb b/spec/helpers/alchemy/admin/ingredients_helper_spec.rb new file mode 100644 index 0000000000..4ccc5c6f39 --- /dev/null +++ b/spec/helpers/alchemy/admin/ingredients_helper_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Alchemy::Admin::IngredientsHelper do + let(:element) { build_stubbed(:alchemy_element, name: "element_with_ingredients") } + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + let(:ingredient_editor) { Alchemy::IngredientEditor.new(ingredient) } + + describe "#ingredient_label" do + subject { helper.ingredient_label(ingredient_editor) } + + it "has for attribute set to ingredient form field id" do + is_expected.to have_selector('label[for="element_ingredients_attributes_0_value"]') + end + + context "with another column given" do + subject { helper.ingredient_label(ingredient_editor, :picture_id) } + + it "has for attribute set to ingredient form field id for that column" do + is_expected.to have_selector('label[for="element_ingredients_attributes_0_picture_id"]') + end + end + + context "with a hint" do + before do + expect(ingredient).to receive(:definition).at_least(:once) do + { hint: "This is a hint" } + end + end + + it "has hint indicator" do + is_expected.to have_selector("label > .hint-with-icon", text: "This is a hint") + end + end + end + + describe "#render_ingredient_role" do + subject { helper.render_ingredient_role(ingredient_editor) } + + it "returns the ingredient name" do + is_expected.to eq("Headline") + end + + context "if ingredient is nil" do + let(:ingredient) { nil } + + it "returns nil" do + is_expected.to be_nil + end + end + + context "with missing definition" do + let(:ingredient) do + mock_model "Alchemy::Ingredients::Text", + role: "intro", + definition: {}, + name_for_label: "Intro", + has_validations?: false, + deprecated?: false, + has_warnings?: true, + warnings: Alchemy.t(:ingredient_definition_missing), + element: element + end + + it "renders a warning with tooltip" do + is_expected.to have_selector(".hint-with-icon .hint-bubble") + is_expected.to have_content Alchemy.t(:ingredient_definition_missing) + end + end + + context "when deprecated" do + let(:ingredient) do + mock_model "Alchemy::Ingredients::Text", + role: "intro", + definition: { name: "intro", type: "Text", deprecated: true }, + name_for_label: "Intro", + has_validations?: false, + deprecated?: true, + has_warnings?: true, + warnings: Alchemy.t(:ingredient_deprecated), + element: element + end + + it "renders a deprecation notice with tooltip" do + is_expected.to have_selector(".hint-bubble", text: Alchemy.t(:ingredient_deprecated)) + end + end + + context "with validations" do + before { expect(ingredient).to receive(:has_validations?).and_return(true) } + + it "show a validation indicator" do + is_expected.to have_selector(".validation_indicator") + end + end + end +end diff --git a/spec/helpers/alchemy/elements_block_helper_spec.rb b/spec/helpers/alchemy/elements_block_helper_spec.rb index 48c40765c7..a4e4c07bcb 100644 --- a/spec/helpers/alchemy/elements_block_helper_spec.rb +++ b/spec/helpers/alchemy/elements_block_helper_spec.rb @@ -68,18 +68,34 @@ module Alchemy end describe "#render" do - let(:element) { create(:alchemy_element, :with_contents) } - let(:content) { element.content_by_name(:headline) } - - it "delegates to Rails' render helper" do - expect(scope).to receive(:render).with(content, { - content: content, - options: { - foo: "bar", - }, - html_options: {}, - }) - subject.render(:headline, foo: "bar") + context "with element having contents" do + let(:element) { create(:alchemy_element, :with_contents) } + let(:content) { element.content_by_name(:headline) } + + it "delegates to Rails' render helper" do + expect(scope).to receive(:render).with(content, { + options: { + foo: "bar", + }, + html_options: {}, + }) + subject.render(:headline, foo: "bar") + end + end + + context "with element having ingredients" do + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredient_by_role(:headline) } + + it "delegates to Rails' render helper" do + expect(scope).to receive(:render).with(ingredient, { + options: { + foo: "bar", + }, + html_options: {}, + }) + subject.render(:headline, foo: "bar") + end end end @@ -91,23 +107,48 @@ module Alchemy end describe "#ingredient" do - it "should delegate to the element's #ingredient method" do - expect(element).to receive(:ingredient).with(:title) - subject.ingredient :title + context "with element having contents" do + it "should delegate to the element's #ingredient method" do + expect(element).to receive(:ingredient).with(:title) + subject.ingredient(:title) + end + end + + context "with element having ingredients" do + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + + it "should return the ingredients value" do + expect(ingredient).to receive(:value).and_call_original + subject.ingredient(:headline) + end end end describe "#has?" do - it "should delegate to the element's #has_ingredient? method" do - expect(element).to receive(:has_ingredient?).with(:title) - subject.has? :title + context "with element having contents" do + it "should delegate to the element's #has_ingredient? method" do + expect(element).to receive(:has_ingredient?).with(:title) + subject.has?(:title) + end + end + + context "with element having ingredients" do + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + + it "should delegate to the element's #has_value? method" do + expect(element).to receive(:has_value_for?).with(:headline) + subject.has?(:headline) + end end end describe "#essence" do it "should provide the specified content essence" do - expect(subject).to receive(:content).with(:title). - and_return(mock_model("Content", essence: mock_model("EssenceText"))) + expect(subject).to receive(:content).with(:title) do + mock_model("Content", essence: mock_model("EssenceText")) + end subject.essence :title end diff --git a/spec/libraries/permissions_spec.rb b/spec/libraries/permissions_spec.rb index 447da182d1..c05faaf990 100644 --- a/spec/libraries/permissions_spec.rb +++ b/spec/libraries/permissions_spec.rb @@ -140,6 +140,7 @@ is_expected.to be_able_to(:manage, Alchemy::EssenceFile) is_expected.to be_able_to(:manage, Alchemy::EssencePicture) is_expected.to be_able_to(:manage, Alchemy::EssenceVideo) + is_expected.to be_able_to(:manage, Alchemy::Ingredient) end it "can manage the clipboard" do diff --git a/spec/libraries/tinymce_spec.rb b/spec/libraries/tinymce_spec.rb index 8d4a2d9a7f..370ab2a88c 100644 --- a/spec/libraries/tinymce_spec.rb +++ b/spec/libraries/tinymce_spec.rb @@ -13,7 +13,7 @@ module Alchemy end describe ".init=" do - let(:another_config) { {theme_advanced_buttons3: "table"} } + let(:another_config) { { theme_advanced_buttons3: "table" } } it "merges the default config with given config" do Tinymce.init = another_config @@ -63,7 +63,7 @@ module Alchemy context "with no contents having custom tinymce config" do let(:content_definition) do - {"name" => "text"} + { "name" => "text" } end it { is_expected.to eq([]) } @@ -118,6 +118,103 @@ module Alchemy end end end + + describe ".custom_config_ingredients" do + let(:page) { build_stubbed(:alchemy_page) } + + let(:element_definition) do + { + "name" => "article", + "ingredients" => [ingredient_definition], + } + end + + let(:element_definitions) do + [element_definition] + end + + let(:ingredient_definition) do + { + "role" => "text", + "settings" => { + "tinymce" => { + "foo" => "bar", + }, + }, + } + end + + subject { Tinymce.custom_config_ingredients(page) } + + before do + expect(page).to receive(:descendent_element_definitions) { element_definitions } + end + + it "returns an array of ingredient definitions that contain custom tinymce config + and element name" do + is_expected.to be_an(Array) + is_expected.to include({ + "element" => element_definition["name"], + }.merge(ingredient_definition)) + end + + context "with no ingredients having custom tinymce config" do + let(:ingredient_definition) do + { "role" => "text" } + end + + it { is_expected.to eq([]) } + end + + context "with element definition having nil as ingredients value" do + let(:element_definition) do + { + "name" => "element", + "ingredients" => nil, + } + end + + it "returns empty array" do + is_expected.to eq([]) + end + end + + context "with ingredient settings tinymce set to true only" do + let(:element_definition) do + { + "name" => "element", + "ingredients" => [ + "role" => "headline", + "settings" => { + "tinymce" => true, + }, + ], + } + end + + it "returns empty array" do + is_expected.to eq([]) + end + end + + context "with nestable_elements defined" do + let(:element_definitions) do + [ + element_definition, + { + "name" => "nested_element", + "ingredients" => [ingredient_definition], + }, + ] + end + + it "includes these configs" do + is_expected.to include({ + "element" => element_definition["name"], + }.merge(ingredient_definition)) + end + end + end end end end diff --git a/spec/models/alchemy/element_ingredients_spec.rb b/spec/models/alchemy/element_ingredients_spec.rb new file mode 100644 index 0000000000..b07ecf7c57 --- /dev/null +++ b/spec/models/alchemy/element_ingredients_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Element do + it { is_expected.to have_many(:ingredients) } + + let(:element) { build(:alchemy_element, :with_ingredients) } + + it "creates ingredients after creation" do + expect { + element.save! + }.to change { element.ingredients.count }.by(2) + end + + describe "#ingredients_by_type" do + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:expected_ingredients) { element.ingredients.texts } + + context "with namespaced essence type" do + subject { element.ingredients_by_type("Alchemy::Text") } + + it { is_expected.not_to be_empty } + + it("should return the correct list of essences") { is_expected.to eq(expected_ingredients) } + end + + context "without namespaced essence type" do + subject { element.ingredients_by_type("Text") } + + it { is_expected.not_to be_empty } + + it("should return the correct list of essences") { is_expected.to eq(expected_ingredients) } + end + end + + describe "#ingredient_by_type" do + let!(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + + context "with namespaced essence type" do + it "should return ingredient by passing a essence type" do + expect(element.ingredient_by_type("Alchemy::Text")).to eq(ingredient) + end + end + + context "without namespaced essence type" do + it "should return ingredient by passing a essence type" do + expect(element.ingredient_by_type("Text")).to eq(ingredient) + end + end + end + + describe "#ingredient_by_role" do + let!(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + + context "with role existing" do + it "should return ingredient" do + expect(element.ingredient_by_role(:headline)).to eq(ingredient) + end + end + + context "role not existing" do + it { expect(element.ingredient_by_role(:foo)).to be_nil } + end + end + + describe "#has_value_for?" do + let!(:element) { create(:alchemy_element, :with_ingredients) } + + context "with role existing" do + let(:ingredient) { element.ingredients.first } + + context "with blank value" do + before do + expect(ingredient).to receive(:value) { nil } + end + + it { expect(element.has_value_for?(:headline)).to be(false) } + end + + context "with value present" do + before do + expect(ingredient).to receive(:value) { "Headline" } + end + + it "should return ingredient" do + expect(element.has_value_for?(:headline)).to be(true) + end + end + end + + context "role not existing" do + it { expect(element.has_value_for?(:foo)).to be(false) } + end + end + + describe "ingredient validations" do + let(:element) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + + before do + element.update( + ingredients_attributes: { + "0": { + id: element.ingredients.first.id, + value: "", + }, + }, + ) + end + + it "validates ingredients on update" do + expect(element.errors[:ingredients]).to be_present + end + end + + describe "#ingredient_error_messages" do + let(:element) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + + before do + element.update( + ingredients_attributes: { + "0": { + id: element.ingredients.first.id, + value: "", + }, + }, + ) + end + + it "returns translated ingredient error messages" do + expect(element.ingredient_error_messages).to eq([ + "Please enter a headline for all you can eat", + "Text is invalid", + "Please select something else", + ]) + end + end +end diff --git a/spec/models/alchemy/element_spec.rb b/spec/models/alchemy/element_spec.rb index d0a32ce186..884af17b39 100644 --- a/spec/models/alchemy/element_spec.rb +++ b/spec/models/alchemy/element_spec.rb @@ -60,7 +60,33 @@ module Alchemy ) end - it "creates contents" do + it "does not create contents" do + expect(element.contents).to be_empty + end + end + + context "if ingredients are defined as well" do + before do + expect_any_instance_of(Alchemy::Element).to receive(:definition).at_least(:once) do + { + name: "article", + contents: [ + { + name: "headline", + type: "EssenceText", + }, + ], + ingredients: [ + { + role: "headline", + type: "Text", + }, + ], + }.with_indifferent_access + end + end + + it "does not create contents" do expect(element.contents).to be_empty end end diff --git a/spec/models/alchemy/ingredient_spec.rb b/spec/models/alchemy/ingredient_spec.rb new file mode 100644 index 0000000000..f0245c6507 --- /dev/null +++ b/spec/models/alchemy/ingredient_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredient do + let(:element) do + build(:alchemy_element, name: "element_with_ingredients", autogenerate_ingredients: false) + end + + it_behaves_like "having a hint" do + let(:subject) { Alchemy::Ingredients::Text.new(role: "headline", element: element) } + end + + describe "scopes" do + let(:element) do + build(:alchemy_element, name: "all_you_can_eat_ingredients", autogenerate_ingredients: false) + end + + %w[ + audio + boolean + datetime + file + headline + html + link + node + page + picture + richtext + select + text + video + ].each do |type| + describe ".#{type}s" do + subject { described_class.send(type.pluralize) } + + let(type.to_sym) { "Alchemy::Ingredients::#{type.classify}".constantize.create(role: type, element: element) } + let!(:ingredients) { [public_send(type)] } + + it "returns only #{type} ingredients" do + is_expected.to eq([public_send(type)]) + end + end + end + end + + describe ".build" do + subject { described_class.build(attributes) } + + context "without element" do + let(:attributes) { {} } + + it { expect { subject }.to raise_error(ArgumentError) } + end + + context "with element" do + context "without role given" do + let(:attributes) { { element: element } } + + it { expect { subject }.to raise_error(ArgumentError) } + end + + context "with role given" do + let(:attributes) { { element: element, role: "headline" } } + + it { is_expected.to be_an(Alchemy::Ingredients::Text) } + end + + context "with default defined" do + let(:attributes) { { element: element, role: "headline" } } + + context "defined as String" do + it "sets default value" do + expect(subject.value).to eq("Hello World") + end + end + + context "defined as Symbol" do + let(:attributes) { { element: element, role: "text" } } + + it "sets translated default value" do + expect(subject.value).to eq("Dapibus nostra massa phasellus viverra rhoncus fringilla") + end + end + end + + context "with undefined role given" do + let(:attributes) { { element: element, role: "foo" } } + + it { expect { subject }.to raise_error(Alchemy::Ingredient::DefinitionError) } + end + end + end + + describe ".create" do + subject { described_class.create(attributes) } + + let(:attributes) { { element: element, role: "headline" } } + + it { expect { subject }.to change(Alchemy::Ingredients::Text, :count).by(1) } + + it "returns self" do + is_expected.to be_an(Alchemy::Ingredients::Text) + end + end + + describe "#settings" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + + it "returns the settings hash from definition" do + expect(ingredient.settings).to eq({ "linkable" => true }) + end + + context "if settings are not defined" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "text", element: element) } + + it "returns empty hash" do + expect(ingredient.settings).to eq({}) + end + end + end + + describe "#settings_value" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + let(:key) { :linkable } + let(:options) { {} } + + subject { ingredient.settings_value(key, options) } + + context "with ingredient having settings" do + context "and empty options" do + it "returns the value for key from ingredient settings" do + expect(subject).to eq(true) + end + end + + context "and nil options" do + let(:options) { nil } + + it "returns the value for key from ingredient settings" do + expect(subject).to eq(true) + end + end + + context "but same key present in options" do + let(:options) { { linkable: false } } + + it "returns the value for key from options" do + expect(subject).to eq(false) + end + end + + context "and key passed as string" do + let(:key) { "linkable" } + + it "returns the value" do + expect(subject).to eq(true) + end + end + end + + context "with ingredient having no settings" do + let(:ingredient) { Alchemy::Ingredients::Richtext.build(role: "text", element: element) } + + context "and empty options" do + let(:options) { {} } + + it { expect(subject).to eq(nil) } + end + + context "but key present in options" do + let(:options) { { linkable: false } } + + it "returns the value for key from options" do + expect(subject).to eq(false) + end + end + end + end + + describe "#partial_name" do + let(:ingredient) { Alchemy::Ingredients::Richtext.build(role: "text", element: element) } + + subject { ingredient.partial_name } + + it "returns the demodulized underscored class name" do + is_expected.to eq "richtext" + end + end + + describe "#to_partial_path" do + let(:ingredient) { Alchemy::Ingredients::Richtext.build(role: "text", element: element) } + + subject { ingredient.to_partial_path } + + it "returns the path to the view partial" do + is_expected.to eq "alchemy/ingredients/richtext_view" + end + end + + describe "#has_validations?" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + + subject { ingredient.has_validations? } + + context "not defined with validations" do + it { is_expected.to be false } + end + + context "defined with validations" do + before do + expect(ingredient).to receive(:definition).at_least(:once).and_return({ + validate: { presence: true }, + }) + end + + it { is_expected.to be true } + end + end + + describe "#has_hint?" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + + subject { ingredient.has_hint? } + + context "not defined with hint" do + it { is_expected.to be false } + end + + context "defined with hint" do + before do + expect(ingredient).to receive(:definition).at_least(:once).and_return({ + hint: true, + }) + end + + it { is_expected.to be true } + end + end + + describe "#deprecated?" do + let(:ingredient) { Alchemy::Ingredients::Text.build(role: "headline", element: element) } + + subject { ingredient.deprecated? } + + context "not defined as deprecated" do + it { is_expected.to be false } + end + + context "defined as deprecated" do + before do + expect(ingredient).to receive(:definition).at_least(:once).and_return({ + deprecated: true, + }) + end + + it { is_expected.to be true } + end + + context "defined as deprecated per String" do + before do + expect(ingredient).to receive(:definition).at_least(:once).and_return({ + deprecated: "This ingredient is deprecated", + }) + end + + it { is_expected.to be true } + end + end + + describe "#has_tinymce?" do + subject { ingredient.has_tinymce? } + + let(:ingredient) { Alchemy::Ingredient.build(role: "headline", element: element) } + + it { is_expected.to be(false) } + end +end diff --git a/spec/models/alchemy/ingredient_validator_spec.rb b/spec/models/alchemy/ingredient_validator_spec.rb new file mode 100644 index 0000000000..5a57647949 --- /dev/null +++ b/spec/models/alchemy/ingredient_validator_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::IngredientValidator do + subject(:validate) { described_class.new.validate(ingredient) } + + context "with a ingredient not having any validations" do + let(:element) { create(:alchemy_element, :with_ingredients) } + let(:ingredient) { element.ingredients.first } + + before { validate } + + it "does not validate" do + expect(ingredient.errors).to be_blank + end + end + + context "with an ingredient having present validation" do + let(:element) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + let(:ingredient) { element.ingredient_by_role(:headline) } + + context "and the value is blank" do + before { validate } + + it { expect(ingredient.errors).to be_present } + it { expect(ingredient.errors.messages).to eq(value: ["can't be blank"]) } + end + + context "and the value is present" do + before do + expect(ingredient).to receive(:value) { "Foo" } + validate + end + + it { expect(ingredient.errors).to be_blank } + end + end + + context "with an ingredient having format validation" do + let(:element) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + let(:ingredient) { element.ingredient_by_role(:text) } + + before do + expect(ingredient).to receive(:value).at_least(:once) { value } + validate + end + + context "and the value is matching" do + let(:value) { "Foo" } + + it { expect(ingredient.errors).to be_blank } + end + + context "and the value is not matching" do + let(:value) { "!" } + + it { expect(ingredient.errors).to be_present } + end + end + + context "with an ingredient having uniqueness validation" do + let(:element) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + let(:ingredient) { element.ingredient_by_role(:select) } + + context "and no other ingredient of same kind has the value" do + before { ingredient.update(value: "B") } + + it { expect(ingredient.errors).to be_blank } + end + + context "and another ingredient of same kind has the value" do + let(:element2) { create(:alchemy_element, :with_ingredients, name: "all_you_can_eat_ingredients") } + + let!(:ingredient2) do + element2.ingredient_by_role(:select).tap do |in2| + in2.update!(value: "B") + end + end + + before do + ingredient.update(value: "B") + end + + it { expect(ingredient.errors).to be_present } + it { expect(ingredient.errors.messages).to eq(value: ["has already been taken"]) } + end + end +end diff --git a/spec/models/alchemy/ingredients/audio_spec.rb b/spec/models/alchemy/ingredients/audio_spec.rb new file mode 100644 index 0000000000..7d31e9b55c --- /dev/null +++ b/spec/models/alchemy/ingredients/audio_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Audio do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:audio_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "podcast", + related_object: attachment, + ) + end + + describe "#autoplay" do + subject { audio_ingredient.autoplay } + before { audio_ingredient.autoplay = false } + it { is_expected.to eq(false) } + end + + describe "#controls" do + subject { audio_ingredient.controls } + before { audio_ingredient.controls = true } + it { is_expected.to eq(true) } + end + + describe "#loop" do + subject { audio_ingredient.loop } + before { audio_ingredient.loop = false } + it { is_expected.to eq(false) } + end + + describe "#muted" do + subject { audio_ingredient.muted } + before { audio_ingredient.muted = true } + it { is_expected.to eq(true) } + end + + describe "#attachment" do + subject { audio_ingredient.attachment } + + it { is_expected.to be_an(Alchemy::Attachment) } + end + + describe "#attachment=" do + let(:attachment) { Alchemy::Attachment.new } + + subject { audio_ingredient.attachment = attachment } + + it { is_expected.to be(attachment) } + end + + describe "#attachment_id" do + subject { audio_ingredient.attachment_id } + + it { is_expected.to be_an(Integer) } + end + + describe "#attachment_id=" do + let(:attachment) { Alchemy::Attachment.new(id: 111) } + + subject { audio_ingredient.attachment_id = attachment.id } + + it { is_expected.to be(111) } + it { expect(audio_ingredient.related_object_type).to eq("Alchemy::Attachment") } + end + + describe "#preview_text" do + subject { audio_ingredient.preview_text } + + context "with a attachment" do + let(:attachment) do + Alchemy::Attachment.new(name: "A very long file name that would not fit") + end + + it "returns first 30 characters of the attachment name" do + is_expected.to eq("A very long file name that wou") + end + end + + context "with no attachment" do + let(:attachment) { nil } + + it { is_expected.to eq("") } + end + end + + describe "#value" do + subject { audio_ingredient.value } + + context "with attachment assigned" do + it "returns attachment" do + is_expected.to be(attachment) + end + end + + context "with no attachment assigned" do + let(:attachment) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/ingredients/boolean_spec.rb b/spec/models/alchemy/ingredients/boolean_spec.rb new file mode 100644 index 0000000000..58588ee36b --- /dev/null +++ b/spec/models/alchemy/ingredients/boolean_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Boolean do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + + let(:boolean_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "has_padding", + value: "1", + ) + end + + describe "value" do + subject { boolean_ingredient.value } + + it "returns a boolean" do + is_expected.to eq(true) + end + + context "without value" do + let(:boolean_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "has_padding", + ) + end + + it { is_expected.to be_nil } + end + end + + describe "preview_text" do + subject { boolean_ingredient.preview_text } + + it "returns localized value" do + is_expected.to eq("True") + end + + context "without value" do + let(:boolean_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "has_padding", + ) + end + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/ingredients/datetime_spec.rb b/spec/models/alchemy/ingredients/datetime_spec.rb new file mode 100644 index 0000000000..9444892f6c --- /dev/null +++ b/spec/models/alchemy/ingredients/datetime_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Datetime do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + + let(:datetime_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "date", + value: "01.04.2021", + ) + end + + describe "value" do + subject { datetime_ingredient.value } + + it "returns a time object" do + is_expected.to be_an(Time) + is_expected.to eq("01.04.2021") + end + + context "without value" do + let(:datetime_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "date", + ) + end + + it { is_expected.to be_nil } + end + end + + describe "preview_text" do + subject { datetime_ingredient.preview_text } + + it "returns a localized date" do + is_expected.to eq("2021-04-01") + end + + context "without date" do + let(:datetime_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "date", + ) + end + + it { is_expected.to eq "" } + end + end +end diff --git a/spec/models/alchemy/ingredients/file_spec.rb b/spec/models/alchemy/ingredients/file_spec.rb new file mode 100644 index 0000000000..dbfd11abf5 --- /dev/null +++ b/spec/models/alchemy/ingredients/file_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::File do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:file_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "download", + related_object: attachment, + ) + end + + describe "css_class" do + before { file_ingredient.css_class = "download" } + subject { file_ingredient.css_class } + + it { is_expected.to eq("download") } + end + + describe "link_text" do + before { file_ingredient.link_text = "Download" } + subject { file_ingredient.link_text } + + it { is_expected.to eq("Download") } + end + + describe "title" do + before { file_ingredient.title = "Click to download" } + subject { file_ingredient.title } + + it { is_expected.to eq("Click to download") } + end + + describe "attachment" do + subject { file_ingredient.attachment } + + it { is_expected.to be_an(Alchemy::Attachment) } + end + + describe "attachment=" do + let(:attachment) { Alchemy::Attachment.new } + + subject { file_ingredient.attachment = attachment } + + it { is_expected.to be(attachment) } + end + + describe "#attachment_id" do + subject { file_ingredient.attachment_id } + + it { + is_expected.to be_an(Integer) + } + end + + describe "#attachment_id=" do + let(:attachment) { Alchemy::Attachment.new(id: 111) } + + subject { file_ingredient.attachment_id = attachment.id } + + it { is_expected.to be(111) } + it { expect(file_ingredient.related_object_type).to eq("Alchemy::Attachment") } + end + + describe "preview_text" do + subject { file_ingredient.preview_text } + + context "with a attachment" do + let(:attachment) do + Alchemy::Attachment.new(name: "A very long file name that would not fit") + end + + it "returns first 30 characters of the attachment name" do + is_expected.to eq("A very long file name that wou") + end + end + + context "with no attachment" do + let(:attachment) { nil } + + it { is_expected.to eq("") } + end + end + + describe "value" do + subject { file_ingredient.value } + + context "with attachment assigned" do + it "returns attachment" do + is_expected.to be(attachment) + end + end + + context "with no attachment assigned" do + let(:attachment) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/ingredients/headline_spec.rb b/spec/models/alchemy/ingredients/headline_spec.rb new file mode 100644 index 0000000000..0376f5cb26 --- /dev/null +++ b/spec/models/alchemy/ingredients/headline_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Headline do + subject(:ingredient) do + described_class.new( + value: value, + level: 2, + size: 3, + ) + end + + let(:value) { "A headline" } + + it_behaves_like "an alchemy ingredient" + + describe "#level_options" do + subject { ingredient.level_options } + + it { is_expected.to eq([["H1", 1], ["H2", 2], ["H3", 3], ["H4", 4], ["H5", 5], ["H6", 6]]) } + + context "when restricted through the ingredient settings" do + before do + expect(ingredient).to receive(:settings).and_return(levels: [2, 3]) + end + + it { is_expected.to eq([["H2", 2], ["H3", 3]]) } + end + end + + describe "#size_options" do + subject { ingredient.size_options } + + it { is_expected.to eq([]) } + + context "when enabled through the ingredient settings" do + before do + expect(ingredient).to receive(:settings).and_return(sizes: [3, 4]) + end + + it { is_expected.to eq([["H3", 3], ["H4", 4]]) } + end + end + + describe "creating from a settings" do + let(:element) { create(:alchemy_element) } + + before do + expect(element).to receive(:ingredient_definition_for).at_least(:once) do + { + role: "headline", + type: "Headline", + settings: { + sizes: [3], + levels: [2, 3], + }, + } + end + end + + it "should have the size and level fields filled with correct defaults" do + ingredient = Alchemy::Ingredient.create(element: element, role: "headline") + expect(ingredient.size).to eq(3) + expect(ingredient.level).to eq(2) + end + end +end diff --git a/spec/models/alchemy/ingredients/html_spec.rb b/spec/models/alchemy/ingredients/html_spec.rb new file mode 100644 index 0000000000..4cd039c4cd --- /dev/null +++ b/spec/models/alchemy/ingredients/html_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Html do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + + let(:html_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "tracking_code", + value: '', + ) + end + + describe "preview_text" do + subject { html_ingredient.preview_text } + + it "return first 30 escaped characters from value" do + is_expected.to eq("<script type="text/jav") + end + + context "without value" do + let(:html_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "tracking_code", + ) + end + + it { is_expected.to eq "" } + end + end +end diff --git a/spec/models/alchemy/ingredients/link_spec.rb b/spec/models/alchemy/ingredients/link_spec.rb new file mode 100644 index 0000000000..c121355795 --- /dev/null +++ b/spec/models/alchemy/ingredients/link_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Link do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + + let(:link_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "headline", + value: "https://example.com", + data: { + link_target: "_blank", + link_title: "Click here", + link_class_name: "button", + }, + ) + end + + describe "#link_target" do + subject { link_ingredient.link_target } + + it { is_expected.to eq("_blank") } + end + + describe "#link_title" do + subject { link_ingredient.link_title } + + it { is_expected.to eq("Click here") } + end + + describe "#link_class_name" do + subject { link_ingredient.link_class_name } + + it { is_expected.to eq("button") } + end + + describe "#link_target=" do + subject { link_ingredient.link_target = "" } + + it { is_expected.to eq("") } + end + + describe "#link_title=" do + subject { link_ingredient.link_title = "Follow me" } + + it { is_expected.to eq("Follow me") } + end + + describe "#link_class_name=" do + subject { link_ingredient.link_class_name = "btn btn-default" } + + it { is_expected.to eq("btn btn-default") } + end + + describe "preview_text" do + subject { link_ingredient.preview_text } + + it "returns first 30 characters of the value" do + is_expected.to eq("https://example.com") + end + end +end diff --git a/spec/models/alchemy/ingredients/node_spec.rb b/spec/models/alchemy/ingredients/node_spec.rb new file mode 100644 index 0000000000..45b082835b --- /dev/null +++ b/spec/models/alchemy/ingredients/node_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Node do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + let(:node) { build_stubbed(:alchemy_node) } + + let(:node_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "menu", + related_object: node, + ) + end + + describe "node" do + subject { node_ingredient.node } + + it { is_expected.to be_an(Alchemy::Node) } + end + + describe "node=" do + let(:node) { Alchemy::Node.new } + + subject { node_ingredient.node = node } + + it { is_expected.to be(node) } + end + + describe "#node_id" do + subject { node_ingredient.node_id } + + it { is_expected.to be_an(Integer) } + end + + describe "#node_id=" do + let(:node) { Alchemy::Node.new(id: 111) } + + subject { node_ingredient.node_id = node.id } + + it { is_expected.to be(111) } + it { expect(node_ingredient.related_object_type).to eq("Alchemy::Node") } + end + + describe "preview_text" do + subject { node_ingredient.preview_text } + + context "with a node" do + let(:node) do + Alchemy::Node.new(name: "A very long node name that would not fit") + end + + it "returns first 30 characters of the nodes name" do + is_expected.to eq("A very long node name that wou") + end + end + + context "with no node" do + let(:node) { nil } + + it { is_expected.to eq("") } + end + end + + describe "value" do + subject { node_ingredient.value } + + context "with node assigned" do + it "returns node" do + is_expected.to be(node) + end + end + + context "with no node assigned" do + let(:node) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/ingredients/page_spec.rb b/spec/models/alchemy/ingredients/page_spec.rb new file mode 100644 index 0000000000..a02b0e54e4 --- /dev/null +++ b/spec/models/alchemy/ingredients/page_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Page do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + let(:page) { build_stubbed(:alchemy_page) } + + let(:page_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "follow_up", + related_object: page, + ) + end + + describe "page" do + subject { page_ingredient.page } + + it { is_expected.to be_an(Alchemy::Page) } + end + + describe "page=" do + let(:page) { Alchemy::Page.new } + + subject { page_ingredient.page = page } + + it { is_expected.to be(page) } + end + + describe "#page_id" do + subject { page_ingredient.page_id } + + it { is_expected.to be_an(Integer) } + end + + describe "#page_id=" do + let(:page) { Alchemy::Page.new(id: 111) } + + subject { page_ingredient.page_id = page.id } + + it { is_expected.to be(111) } + it { expect(page_ingredient.related_object_type).to eq("Alchemy::Page") } + end + + describe "preview_text" do + subject { page_ingredient.preview_text } + + context "with a page" do + let(:page) do + Alchemy::Page.new(name: "A very long page name that would not fit") + end + + it "returns first 30 characters of the pages name" do + is_expected.to eq("A very long page name that wou") + end + end + + context "with no page" do + let(:page) { nil } + + it { is_expected.to eq("") } + end + end + + describe "value" do + subject { page_ingredient.value } + + context "with page assigned" do + it "returns page" do + is_expected.to be(page) + end + end + + context "with no page assigned" do + let(:page) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/ingredients/picture_spec.rb b/spec/models/alchemy/ingredients/picture_spec.rb new file mode 100644 index 0000000000..7c701b628d --- /dev/null +++ b/spec/models/alchemy/ingredients/picture_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Picture do + it_behaves_like "an alchemy ingredient" + + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:picture) { build_stubbed(:alchemy_picture) } + + let(:picture_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "image", + related_object: picture, + ) + end + + describe "alt_tag" do + before { picture_ingredient.alt_tag = "A cute kitten" } + subject { picture_ingredient.alt_tag } + + it { is_expected.to eq("A cute kitten") } + end + + describe "css_class" do + before { picture_ingredient.css_class = "download" } + subject { picture_ingredient.css_class } + + it { is_expected.to eq("download") } + end + + describe "link_title" do + before { picture_ingredient.link_title = "Nice picture" } + subject { picture_ingredient.link_title } + + it { is_expected.to eq("Nice picture") } + end + + describe "title" do + before { picture_ingredient.title = "Click to view" } + subject { picture_ingredient.title } + + it { is_expected.to eq("Click to view") } + end + + describe "picture" do + subject { picture_ingredient.picture } + + it { is_expected.to be_an(Alchemy::Picture) } + end + + describe "picture=" do + let(:picture) { Alchemy::Picture.new } + + subject { picture_ingredient.picture = picture } + + it { is_expected.to be(picture) } + end + + describe "#picture_id" do + subject { picture_ingredient.picture_id } + + it { + is_expected.to be_an(Integer) + } + end + + describe "#picture_id=" do + let(:picture) { Alchemy::Picture.new(id: 111) } + + subject { picture_ingredient.picture_id = picture.id } + + it { is_expected.to be(111) } + it { expect(picture_ingredient.related_object_type).to eq("Alchemy::Picture") } + end + + describe "preview_text" do + subject { picture_ingredient.preview_text } + + context "with a picture" do + let(:picture) do + Alchemy::Picture.new(name: "A very long picture name that would not fit") + end + + it "returns first 30 characters of the picture name" do + is_expected.to eq("A very long picture name that ") + end + end + + context "with no picture" do + let(:picture) { nil } + + it { is_expected.to eq("") } + end + end + + describe "value" do + subject { picture_ingredient.value } + + context "with picture assigned" do + it "returns picture" do + is_expected.to be(picture) + end + end + + context "with no picture assigned" do + let(:picture) { nil } + + it { is_expected.to be_nil } + end + end + + it_behaves_like "having picture thumbnails" do + let(:element) { build(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:picture) { build(:alchemy_picture) } + + let(:record) do + described_class.new( + element: element, + type: described_class.name, + role: "picture", + picture: picture, + ) + end + end +end diff --git a/spec/models/alchemy/ingredients/richtext_spec.rb b/spec/models/alchemy/ingredients/richtext_spec.rb new file mode 100644 index 0000000000..03739694d2 --- /dev/null +++ b/spec/models/alchemy/ingredients/richtext_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Richtext do + it_behaves_like "an alchemy ingredient" + + let(:element) do + build(:alchemy_element, name: "element_with_ingredients", autogenerate_ingredients: false) + end + + let(:richtext_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "text", + value: "

    Hello!

    Welcome to Peters Petshop.

    ", + ) + end + + it "has a HTML tag free version of body column" do + richtext_ingredient.save + expect(richtext_ingredient.stripped_body).to eq("Hello!Welcome to Peters Petshop.") + end + + it "has a sanitized version of body column" do + richtext_ingredient.save + expect(richtext_ingredient.sanitized_body).to eq("

    Hello!

    Welcome to Peters Petshop.

    ") + end + + describe "#tinymce_class_name" do + subject { richtext_ingredient.tinymce_class_name } + + it { is_expected.to eq("has_tinymce") } + + context "having custom tinymce config" do + before do + expect(richtext_ingredient).to receive(:settings) do + { tinymce: { toolbar: [] } } + end + end + + it "returns role including element name" do + is_expected.to eq("has_tinymce element_with_ingredients_text") + end + end + end + + describe "#has_tinymce?" do + subject { richtext_ingredient.has_tinymce? } + + it { is_expected.to be(true) } + end + + describe "preview_text" do + subject { richtext_ingredient.tap(&:save).preview_text } + + it "returns the first 30 chars of the stripped body column" do + is_expected.to eq("Hello!Welcome to Peters Petsho") + end + end +end diff --git a/spec/models/alchemy/ingredients/select_spec.rb b/spec/models/alchemy/ingredients/select_spec.rb new file mode 100644 index 0000000000..fde2629a72 --- /dev/null +++ b/spec/models/alchemy/ingredients/select_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Select do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + + let(:select_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "color", + value: "A very nice bright color for the button", + ) + end + + describe "preview_text" do + subject { select_ingredient.preview_text } + + it "returns first 30 characters of value" do + is_expected.to eq("A very nice bright color for t") + end + end +end diff --git a/spec/models/alchemy/ingredients/text_spec.rb b/spec/models/alchemy/ingredients/text_spec.rb new file mode 100644 index 0000000000..9f8f874b73 --- /dev/null +++ b/spec/models/alchemy/ingredients/text_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Text do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element, name: "element_with_ingredients") } + + let(:text_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "headline", + value: "A brown fox quickly jumps over the lazy dog", + data: { + link: "https://example.com", + link_target: "_blank", + link_title: "Click here", + link_class_name: "button", + }, + ) + end + + describe "#link" do + subject { text_ingredient.link } + + it { is_expected.to eq("https://example.com") } + end + + describe "#link_target" do + subject { text_ingredient.link_target } + + it { is_expected.to eq("_blank") } + end + + describe "#link_title" do + subject { text_ingredient.link_title } + + it { is_expected.to eq("Click here") } + end + + describe "#link_class_name" do + subject { text_ingredient.link_class_name } + + it { is_expected.to eq("button") } + end + + describe "#link=" do + before { text_ingredient.link = "https://foobar.io" } + subject { text_ingredient.link } + it { is_expected.to eq("https://foobar.io") } + end + + describe "#link_target=" do + before { text_ingredient.link_target = "" } + subject { text_ingredient.link_target } + it { is_expected.to eq("") } + end + + describe "#link_title=" do + before { text_ingredient.link_title = "Follow me" } + subject { text_ingredient.link_title } + it { is_expected.to eq("Follow me") } + end + + describe "#link_class_name=" do + before { text_ingredient.link_class_name = "btn btn-default" } + subject { text_ingredient.link_class_name } + it { is_expected.to eq("btn btn-default") } + end + + describe "preview_text" do + subject { text_ingredient.preview_text } + + it "returns the first 30 chars of the value" do + is_expected.to eq("A brown fox quickly jumps over") + end + end +end diff --git a/spec/models/alchemy/ingredients/video_spec.rb b/spec/models/alchemy/ingredients/video_spec.rb new file mode 100644 index 0000000000..beb5747d43 --- /dev/null +++ b/spec/models/alchemy/ingredients/video_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::Ingredients::Video do + it_behaves_like "an alchemy ingredient" + + let(:element) { build(:alchemy_element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:video_ingredient) do + described_class.new( + element: element, + type: described_class.name, + role: "podcast", + related_object: attachment, + ) + end + + describe "#allow_fullscreen" do + subject { video_ingredient.allow_fullscreen } + before { video_ingredient.allow_fullscreen = true } + it { is_expected.to eq(true) } + end + + describe "#autoplay" do + subject { video_ingredient.autoplay } + before { video_ingredient.autoplay = false } + it { is_expected.to eq(false) } + end + + describe "#controls" do + subject { video_ingredient.controls } + before { video_ingredient.controls = true } + it { is_expected.to eq(true) } + end + + describe "#height" do + subject { video_ingredient.height } + before { video_ingredient.height = 720 } + it { is_expected.to eq(720) } + end + + describe "#loop" do + subject { video_ingredient.loop } + before { video_ingredient.loop = false } + it { is_expected.to eq(false) } + end + + describe "#muted" do + subject { video_ingredient.muted } + before { video_ingredient.muted = true } + it { is_expected.to eq(true) } + end + + describe "#preload" do + subject { video_ingredient.preload } + before { video_ingredient.preload = "auto" } + it { is_expected.to eq("auto") } + end + + describe "#width" do + subject { video_ingredient.width } + before { video_ingredient.width = 1280 } + it { is_expected.to eq(1280) } + end + + describe "#attachment" do + subject { video_ingredient.attachment } + + it { is_expected.to be_an(Alchemy::Attachment) } + end + + describe "#attachment=" do + let(:attachment) { Alchemy::Attachment.new } + + subject { video_ingredient.attachment = attachment } + + it { is_expected.to be(attachment) } + end + + describe "#attachment_id" do + subject { video_ingredient.attachment_id } + + it { is_expected.to be_an(Integer) } + end + + describe "#attachment_id=" do + let(:attachment) { Alchemy::Attachment.new(id: 111) } + + subject { video_ingredient.attachment_id = attachment.id } + + it { is_expected.to be(111) } + it { expect(video_ingredient.related_object_type).to eq("Alchemy::Attachment") } + end + + describe "#preview_text" do + subject { video_ingredient.preview_text } + + context "with a attachment" do + let(:attachment) do + Alchemy::Attachment.new(name: "A very long file name that would not fit") + end + + it "returns first 30 characters of the attachment name" do + is_expected.to eq("A very long file name that wou") + end + end + + context "with no attachment" do + let(:attachment) { nil } + + it { is_expected.to eq("") } + end + end + + describe "#value" do + subject { video_ingredient.value } + + context "with attachment assigned" do + it "returns attachment" do + is_expected.to be(attachment) + end + end + + context "with no attachment assigned" do + let(:attachment) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index c6b6014e9a..ad907047ec 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -1933,6 +1933,62 @@ class AnotherUrlPathClass; end end end + describe "#richtext_ingredients_ids" do + let!(:page) { create(:alchemy_page) } + + let!(:expanded_element) do + create :alchemy_element, :with_ingredients, + name: "element_with_ingredients", + page_version: page.draft_version, + folded: false + end + + let!(:folded_element) do + create :alchemy_element, :with_ingredients, + name: "element_with_ingredients", + page_version: page.draft_version, + folded: true + end + + subject(:richtext_ingredients_ids) { page.richtext_ingredients_ids } + + it "returns ingredient ids for all expanded elements that have tinymce enabled" do + expanded_rtf_ingredients = expanded_element.ingredients.richtexts + expect(richtext_ingredients_ids).to eq(expanded_rtf_ingredients.pluck(:id)) + folded_rtf_ingredient = folded_element.ingredients.richtexts.first + expect(richtext_ingredients_ids).to_not include(folded_rtf_ingredient.id) + end + + context "with nested elements" do + let!(:nested_expanded_element) do + create :alchemy_element, :with_ingredients, + name: "element_with_ingredients", + page_version: page.draft_version, + parent_element: expanded_element, + folded: false + end + + let!(:nested_folded_element) do + create :alchemy_element, :with_ingredients, + name: "element_with_ingredients", + page_version: page.draft_version, + parent_element: folded_element, + folded: true + end + + it "returns ingredient ids for all expanded nested elements that have tinymce enabled" do + expanded_rtf_ingredients = expanded_element.ingredients.richtexts + nested_expanded_rtf_ingredients = nested_expanded_element.ingredients.richtexts + rtf_ingredient_ids = expanded_rtf_ingredients.pluck(:id) + nested_expanded_rtf_ingredients.pluck(:id) + expect(richtext_ingredients_ids.sort).to eq(rtf_ingredient_ids) + + nested_folded_rtf_ingredient = nested_folded_element.ingredients.richtexts.first + + expect(richtext_ingredients_ids).to_not include(nested_folded_rtf_ingredient.id) + end + end + end + describe "#fixed_attributes" do let(:page) { Alchemy::Page.new } diff --git a/spec/models/alchemy/site_spec.rb b/spec/models/alchemy/site_spec.rb index f80654df50..896ecc4847 100644 --- a/spec/models/alchemy/site_spec.rb +++ b/spec/models/alchemy/site_spec.rb @@ -50,7 +50,7 @@ module Alchemy # No need to create a default site, as it has already been added through the seeds. # But let's add some more: # - let(:default_site) { Site.default } + let(:default_site) { Site.default } let!(:magiclabs_site) { create(:alchemy_site, host: "www.magiclabs.de", aliases: "magiclabs.de magiclabs.com www.magiclabs.com") } subject { Site.find_for_host(host) } @@ -108,7 +108,7 @@ module Alchemy subject { Site.definitions } context "with file present" do - let(:definitions) { [{"name" => "lala"}] } + let(:definitions) { [{ "name" => "lala" }] } before { expect(YAML).to receive(:load_file).and_return(definitions) } it { is_expected.to eq(definitions) } end @@ -166,7 +166,7 @@ module Alchemy describe "#definition" do let(:site) { Site.new(name: "My custom site") } - let(:definitions) { [{"name" => "my_custom_site", "page_layouts" => %w(standard)}] } + let(:definitions) { [{ "name" => "my_custom_site", "page_layouts" => %w(standard) }] } it "returns layout definition from site_layouts.yml file" do allow(Site).to receive(:definitions).and_return(definitions) @@ -257,6 +257,12 @@ module Alchemy { "name" => "index", "unique" => true, + "elements" => [ + "all_you_can_eat_ingredients", + ], + "autogenerate" => [ + "all_you_can_eat_ingredients", + ], }, { "name" => "readonly", @@ -301,6 +307,8 @@ module Alchemy "right_column", "left_column", "old", + "all_you_can_eat_ingredients", + "element_with_ingredients", ], }, { diff --git a/spec/presenters/alchemy/picture_view_spec.rb b/spec/presenters/alchemy/picture_view_spec.rb new file mode 100644 index 0000000000..59f48b3215 --- /dev/null +++ b/spec/presenters/alchemy/picture_view_spec.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::PictureView do + include Capybara::RSpecMatchers + + let(:image) do + File.new(File.expand_path("../../fixtures/image.png", __dir__)) + end + + let(:picture) do + stub_model Alchemy::Picture, + image_file_format: "png", + image_file: image + end + + let(:ingredient) do + stub_model Alchemy::Ingredients::Picture, + role: "image", + picture: picture, + data: { + caption: "This is a cute cat", + } + end + + let(:picture_url) { "/pictures/1/image.png" } + + before do + 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 + {} + end + + let(:html_options) do + {} + end + + subject(:view) do + described_class.new(ingredient, options, html_options).render + end + + it "should enclose the image in a
    element" do + expect(view).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") + end + + it "does not pass default options to picture url" do + expect(ingredient).to receive(:picture_url).with({}) { picture_url } + view + end + + context "but disabled in the options" do + let(:options) do + { show_caption: false } + end + + it "should not enclose the image in a
    element" do + expect(view).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") + end + end + + context "but disabled in the ingredient settings" do + before do + allow(ingredient).to receive(:settings).and_return({ show_caption: false }) + end + + it "should not enclose the image in a
    element" do + expect(view).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") + 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") + end + + it "should show the caption" do + expect(view).to have_selector("figure figcaption") + expect(view).to have_content("This is a cute cat") + end + end + end + + context "and essence with css class" do + before do + ingredient.css_class = "left" + end + + it "should have the class on the
    element" do + expect(view).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") + end + end + + context "and css class in the html_options" do + before do + html_options[:class] = "right" + end + + it "should have the class from the html_options on the
    element" do + expect(view).to have_selector("figure.right img") + end + + it "should not have the class from the essence on the
    element" do + expect(view).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") + end + end + end + + context "with link" do + let(:options) do + {} + end + + subject(:view) do + ingredient.link = "/home" + described_class.new(ingredient, options).render + end + + it "should enclose the image in a link tag" do + expect(view).to have_selector('a[href="/home"] img') + end + + context "but disabled link option" do + before do + options[:disable_link] = true + end + + it "should not enclose the image in a link tag" do + expect(view).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 + { srcset: srcset } + end + end + + subject(:view) do + described_class.new(ingredient).render + end + + let(:srcset) do + [] + end + + it "does not pass srcset option to picture_url" do + expect(ingredient).to receive(:picture_url).with({}) { picture_url } + view + end + + context "when only width or width and height are set" do + let(:srcset) do + %w(1024x768 800x) + end + + 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\"]") + end + end + + context "when only height is set" do + let(:srcset) do + %w(x768 x600) + end + + 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\"]") + end + end + end + + context "with no srcset ingredient setting" do + subject(:view) do + described_class.new(ingredient).render + end + + it "image tag has no srcset attribute" do + expect(view).not_to have_selector("img[srcset]") + end + end + + context "with sizes ingredient setting" do + before do + allow(ingredient).to receive(:settings) do + { sizes: sizes } + end + end + + subject(:view) do + described_class.new(ingredient).render + end + + let(:sizes) do + [ + "(max-width: 1023px) 100vh", + "(min-width: 1024px) 33.333vh", + ] + end + + it "does not pass sizes option to picture_url" do + expect(ingredient).to receive(:picture_url).with({}) { picture_url } + view + end + + it "adds sizes attribute for each size" do + expect(view).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 + end + + it "image tag has no sizes attribute" do + expect(view).not_to have_selector("img[sizes]") + end + end + + describe "alt text" do + subject(:view) do + described_class.new(ingredient, {}, html_options).render + end + + let(:html_options) { {} } + + context "essence having alt text stored" do + let(:ingredient) do + stub_model Alchemy::Ingredients::Picture, + picture: picture, + alt_tag: "A cute cat" + end + + it "uses this as image alt text" do + expect(view).to have_selector('img[alt="A cute cat"]') + end + end + + context "essence not having alt text stored" do + context "but passed as html option" do + let(:html_options) { { alt: "Cute kittens" } } + + it "uses this as image alt text" do + expect(view).to have_selector('img[alt="Cute kittens"]') + end + end + + context "and not passed as html option" do + context "with name on the picture" do + let(:picture) do + stub_model Alchemy::Picture, + image_file_format: "png", + image_file: image, + name: "cute_kitty-cat" + end + + it "uses a humanized picture name as alt text" do + expect(view).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]") + end + end + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ce96f578c1..6cd534f92f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -22,6 +22,8 @@ require "alchemy/test_support/essence_shared_examples" require "alchemy/test_support/having_crop_action_examples" require "alchemy/test_support/having_picture_thumbnails_examples" +require "alchemy/test_support/shared_ingredient_examples" +require "alchemy/test_support/shared_ingredient_editor_examples" require "alchemy/test_support/integration_helpers" require "alchemy/test_support/shared_contexts" require "alchemy/test_support/shared_uploader_examples" diff --git a/spec/support/hint_examples.rb b/spec/support/hint_examples.rb index 031638d3bd..6d54e8921c 100644 --- a/spec/support/hint_examples.rb +++ b/spec/support/hint_examples.rb @@ -7,7 +7,7 @@ module Alchemy describe "#hint" do context "with hint as text" do before do - expect(subject).to receive(:definition).and_return({"hint" => "The hint"}) + expect(subject).to receive(:definition).and_return({ hint: "The hint" }) end it "returns the hint" do @@ -17,7 +17,7 @@ module Alchemy context "with hint set to true" do before do - expect(subject).to receive(:definition).and_return({"hint" => true}) + expect(subject).to receive(:definition).and_return({ hint: true }) expect(Alchemy).to receive(:t).and_return("The hint") end diff --git a/spec/views/alchemy/ingredients/audio_editor_spec.rb b/spec/views/alchemy/ingredients/audio_editor_spec.rb new file mode 100644 index 0000000000..208b13af2b --- /dev/null +++ b/spec/views/alchemy/ingredients/audio_editor_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_audio_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Audio, + element: element, + attachment: attachment, + role: "file", + ) + end + + let(:audio_editor) { Alchemy::IngredientEditor.new(ingredient) } + let(:settings) { {} } + + subject do + render element_editor + rendered + end + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + allow(ingredient).to receive(:settings) { settings } + allow(audio_editor).to receive(:attachment) { attachment } + end + + it_behaves_like "an alchemy ingredient editor" + + context "with attachment present" do + it "renders a hidden field with attachment id" do + is_expected.to have_selector("input[type='hidden'][value='#{attachment.id}']") + end + + it "renders a link to open the attachment library overlay" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id']") + end + end + + it "renders a link to edit the ingredient" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/ingredients/#{ingredient.id}/edit']") + end + end + + context "with settings `only`" do + let(:settings) { { only: "mp3" } } + + it "renders a link to open the attachment library overlay with only mp3s" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&only=mp3']") + end + end + end + + context "with settings `except`" do + let(:settings) { { except: "mp3" } } + + it "renders a link to open the attachment library overlay without mp3s" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&except=mp3']") + end + end + end + end + + context "without attachment present" do + let(:attachment) { nil } + + it "renders a hidden field for attachment_id" do + is_expected.to have_selector("input[type='hidden'][name='element[ingredients_attributes][0][attachment_id]']") + end + end +end diff --git a/spec/views/alchemy/ingredients/audio_view_spec.rb b/spec/views/alchemy/ingredients/audio_view_spec.rb new file mode 100644 index 0000000000..94198d90f1 --- /dev/null +++ b/spec/views/alchemy/ingredients/audio_view_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_audio_view" do + let(:file) do + File.new(File.expand_path("../../../fixtures/image with spaces.png", __dir__)) + end + + let(:attachment) do + build_stubbed(:alchemy_attachment, file: file, name: "a podcast", file_name: "image with spaces.png") + end + + let(:ingredient) do + Alchemy::Ingredients::Audio.new( + role: "video", + attachment: attachment, + autoplay: true, + controls: true, + loop: true, + muted: true, + ) + end + + context "without attachment" do + let(:ingredient) { Alchemy::Ingredients::Audio.new(attachment: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end + + context "with attachment" do + it "renders a audio tag with source" do + render ingredient + expect(rendered).to have_selector( + "audio[controls][muted][loop][autoplay] source[src]" + ) + end + end +end diff --git a/spec/views/alchemy/ingredients/boolean_editor_spec.rb b/spec/views/alchemy/ingredients/boolean_editor_spec.rb new file mode 100644 index 0000000000..29e13af785 --- /dev/null +++ b/spec/views/alchemy/ingredients/boolean_editor_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_boolean_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + + let(:ingredient) do + Alchemy::Ingredients::Boolean.build(role: "boolean", type: "Boolean", element: element) + end + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send :include, Alchemy::Admin::BaseHelper + view.class.send :include, Alchemy::Admin::IngredientsHelper + end + + subject do + render element_editor + rendered + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a checkbox" do + is_expected.to have_selector('input[type="checkbox"]') + end + + context "with default value given in ingredient settings" do + before do + expect(element).to receive(:ingredient_definition_for) { ingredient_definition } + allow_any_instance_of(Alchemy::Ingredients::Boolean).to receive(:definition) { ingredient_definition } + end + + let(:ingredient_definition) do + { + role: "boolean", + type: "Boolean", + default: true, + }.with_indifferent_access + end + + it "checks the checkbox" do + is_expected.to have_selector('input[type="checkbox"][checked="checked"]') + end + end +end diff --git a/spec/views/alchemy/ingredients/boolean_view_spec.rb b/spec/views/alchemy/ingredients/boolean_view_spec.rb new file mode 100644 index 0000000000..f2c292bb00 --- /dev/null +++ b/spec/views/alchemy/ingredients/boolean_view_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_boolean_view" do + context "with true as value" do + let(:ingredient) { Alchemy::Ingredients::Boolean.new(value: true) } + + it "renders true" do + render ingredient + expect(rendered).to have_content("True") + end + end + + context "with false as value" do + let(:ingredient) { Alchemy::Ingredients::Boolean.new(value: false) } + + it "renders false" do + render ingredient + expect(rendered).to have_content("False") + end + end +end diff --git a/spec/views/alchemy/ingredients/datetime_editor_spec.rb b/spec/views/alchemy/ingredients/datetime_editor_spec.rb new file mode 100644 index 0000000000..0aaf9d634a --- /dev/null +++ b/spec/views/alchemy/ingredients/datetime_editor_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_datetime_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Datetime.build(role: "datetime", element: element) } + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send(:include, Alchemy::Admin::BaseHelper) + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a datepicker" do + render element_editor + expect(rendered).to have_css('input[type="text"][data-datepicker-type="date"].date') + end +end diff --git a/spec/views/alchemy/ingredients/datetime_view_spec.rb b/spec/views/alchemy/ingredients/datetime_view_spec.rb new file mode 100644 index 0000000000..596e1ac7b1 --- /dev/null +++ b/spec/views/alchemy/ingredients/datetime_view_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_essence_date_view" do + let(:ingredient) { Alchemy::Ingredients::Datetime.new(value: "2013-10-27 21:14:16 +0100") } + let(:options) { {} } + + before do + allow(view).to receive(:options).and_return(options) + end + + context "with date value" do + context "without date_format passed" do + it "translates the date value with default format" do + render ingredient + expect(rendered).to have_content("Sun, 27 Oct 2013 20:14:16 +0000") + end + end + + context "with option date_format set to rfc822" do + let(:options) { { date_format: "rfc822" } } + + it "renders the date rfc822 conform" do + render ingredient + expect(rendered).to have_content("Sun, 27 Oct 2013 20:14:16 +0000") + end + end + end + + context "with blank date value" do + let(:ingredient) { Alchemy::Ingredients::Datetime.new(value: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end +end diff --git a/spec/views/alchemy/ingredients/file_editor_spec.rb b/spec/views/alchemy/ingredients/file_editor_spec.rb new file mode 100644 index 0000000000..8cc291eb94 --- /dev/null +++ b/spec/views/alchemy/ingredients/file_editor_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_file_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::File, + element: element, + attachment: attachment, + role: "file", + ) + end + + let(:file_editor) { Alchemy::IngredientEditor.new(ingredient) } + let(:settings) { {} } + + subject do + render element_editor + rendered + end + + before do + allow(element_editor).to receive(:ingredients) { [file_editor] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + allow(ingredient).to receive(:settings) { settings } + allow(file_editor).to receive(:attachment) { attachment } + end + + it_behaves_like "an alchemy ingredient editor" + + context "with attachment present" do + it "renders a hidden field with attachment id" do + is_expected.to have_selector("input[type='hidden'][value='#{attachment.id}']") + end + + it "renders a link to open the attachment library overlay" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id']") + end + end + + it "renders a link to edit the ingredient" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/ingredients/#{ingredient.id}/edit']") + end + end + + context "with settings `only`" do + let(:settings) { { only: "pdf" } } + + it "renders a link to open the attachment library overlay with only pdfs" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&only=pdf']") + end + end + end + + context "with settings `except`" do + let(:settings) { { except: "pdf" } } + + it "renders a link to open the attachment library overlay without pdfs" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&except=pdf']") + end + end + end + end + + context "without attachment present" do + let(:attachment) { nil } + + it "renders a hidden field for attachment_id" do + is_expected.to have_selector("input[type='hidden'][name='element[ingredients_attributes][0][attachment_id]']") + end + end +end diff --git a/spec/views/alchemy/ingredients/file_view_spec.rb b/spec/views/alchemy/ingredients/file_view_spec.rb new file mode 100644 index 0000000000..2e8c30067f --- /dev/null +++ b/spec/views/alchemy/ingredients/file_view_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_file_view" do + let(:file) do + File.new(File.expand_path("../../../fixtures/image with spaces.png", __dir__)) + end + + let(:attachment) do + build_stubbed(:alchemy_attachment, file: file, name: "an image", file_name: "image with spaces.png") + end + + let(:ingredient) { Alchemy::Ingredients::File.new(attachment: attachment) } + let(:options) { {} } + let(:html_options) { {} } + + subject do + render( + ingredient, + options: options, + html_options: html_options, + ) + rendered + end + + context "without attachment" do + let(:ingredient) { Alchemy::Ingredients::File.new(attachment: nil) } + + it "renders nothing" do + is_expected.to eq("") + end + end + + context "with attachment" do + it "renders a link to download the attachment" do + is_expected.to have_selector( + "a[href='/attachment/#{attachment.id}/download/#{attachment.slug}.#{attachment.suffix}']" + ) + end + + context "with no link_text set" do + it "has this attachments name as link text" do + is_expected.to have_selector("a:contains('#{attachment.name}')") + end + end + + context "with link_text set in the local options" do + let(:options) do + { link_text: "Download this file" } + end + + it "has this value as link text" do + is_expected.to have_selector("a:contains('Download this file')") + end + end + + context "with link_text set in the ingredient settings" do + before do + allow(ingredient).to receive(:settings) { { link_text: "Download this file" } } + end + + it "has this value as link text" do + is_expected.to have_selector("a:contains('Download this file')") + end + end + + context "with link_text stored in the ingredient attribute" do + before do + allow(ingredient).to receive(:link_text) { "Download this file" } + end + + it "has this value as link text" do + is_expected.to have_selector("a:contains('Download this file')") + end + end + + context "with html_options given" do + let(:html_options) do + { title: "Bar", class: "blue" } + end + + it "renders the linked ingredient with these options" do + is_expected.to have_selector('a.blue[title="Bar"]') + end + end + end + + context "with css_class set" do + before do + allow(ingredient).to receive(:css_class) { "file-download" } + end + + it "has this class at the link" do + is_expected.to have_selector("a.file-download") + end + end +end diff --git a/spec/views/alchemy/ingredients/headline_editor_spec.rb b/spec/views/alchemy/ingredients/headline_editor_spec.rb new file mode 100644 index 0000000000..81207d6d4e --- /dev/null +++ b/spec/views/alchemy/ingredients/headline_editor_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_headline_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Headline, + element: element, + role: "headline", + ) + end + + let(:headline_editor) { Alchemy::IngredientEditor.new(ingredient) } + let(:settings) { {} } + + subject do + render element_editor + rendered + end + + before do + allow(element_editor).to receive(:ingredients) { [headline_editor] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + allow(ingredient).to receive(:settings) { settings } + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a text input" do + is_expected.to have_selector("input[type='text'][name='element[ingredients_attributes][0][value]']") + end + + it "renders a level select" do + is_expected.to have_selector("select[name='element[ingredients_attributes][0][level]']") + end + + context "when only one level is given" do + let(:settings) do + { levels: [1] } + end + + it "does not render a level select" do + is_expected.to_not have_selector("select[name='element[ingredients_attributes][0][level]']") + end + end + + it "does not render a size select" do + is_expected.to_not have_selector("select[name='element[ingredients_attributes][0][size]']") + end + + context "when sizes are given" do + let(:settings) do + { sizes: [1, 2] } + end + + it "renders a size select" do + is_expected.to have_selector("select[name='element[ingredients_attributes][0][size]']") + end + end +end diff --git a/spec/views/alchemy/ingredients/headline_view_spec.rb b/spec/views/alchemy/ingredients/headline_view_spec.rb new file mode 100644 index 0000000000..19679e4a33 --- /dev/null +++ b/spec/views/alchemy/ingredients/headline_view_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_headline_view" do + let(:ingredient) { Alchemy::Ingredients::Headline.new(value: "Hello", level: 2) } + + it "renders headline for level" do + render ingredient + expect(rendered).to have_selector("h2") + expect(rendered).to have_content("Hello") + end + + context "without size" do + it "does not add size class" do + render ingredient + expect(rendered).to_not have_selector(".h1") + end + end + + context "with size" do + let(:ingredient) { Alchemy::Ingredients::Headline.new(value: "Hello", level: 2, size: 1) } + + it "adds size class" do + render ingredient + expect(rendered).to have_selector("h2.h1") + end + end +end diff --git a/spec/views/alchemy/ingredients/html_editor_spec.rb b/spec/views/alchemy/ingredients/html_editor_spec.rb new file mode 100644 index 0000000000..ae442c1ab6 --- /dev/null +++ b/spec/views/alchemy/ingredients/html_editor_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_html_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Html, + element: element, + role: "source", + ) + end + + let(:html_editor) { Alchemy::IngredientEditor.new(ingredient) } + let(:settings) { {} } + + subject do + render element_editor + rendered + end + + before do + allow(element_editor).to receive(:ingredients) { [html_editor] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a textarea" do + is_expected.to have_selector("textarea[name='element[ingredients_attributes][0][value]']") + end +end diff --git a/spec/views/alchemy/ingredients/html_view_spec.rb b/spec/views/alchemy/ingredients/html_view_spec.rb new file mode 100644 index 0000000000..4a5b20f48f --- /dev/null +++ b/spec/views/alchemy/ingredients/html_view_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_html_view" do + let(:ingredient) { Alchemy::Ingredients::Html.new(value: '') } + + context "without value" do + let(:ingredient) { Alchemy::Ingredients::Html.new(value: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end + + context "with value" do + it "renders the raw html source" do + render ingredient + expect(rendered).to have_selector("script") + end + end +end diff --git a/spec/views/alchemy/ingredients/link_editor_spec.rb b/spec/views/alchemy/ingredients/link_editor_spec.rb new file mode 100644 index 0000000000..7ca744d270 --- /dev/null +++ b/spec/views/alchemy/ingredients/link_editor_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_link_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Link, + element: element, + role: "link", + ) + end + + subject do + render element_editor + rendered + end + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a disabled text input field" do + is_expected.to have_selector('input[type="text"][disabled]') + end + + it "renders link buttons" do + is_expected.to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][value]"]') + is_expected.to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_title]"]') + is_expected.to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_class_name]"]') + is_expected.to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_target]"]') + end +end diff --git a/spec/views/alchemy/ingredients/link_view_spec.rb b/spec/views/alchemy/ingredients/link_view_spec.rb new file mode 100644 index 0000000000..dc21300a6e --- /dev/null +++ b/spec/views/alchemy/ingredients/link_view_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_link_view" do + let(:ingredient) { Alchemy::Ingredients::Link.new(value: "http://google.com") } + + context "without value" do + let(:ingredient) { Alchemy::Ingredients::Link.new(value: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end + + it "renders a link" do + render ingredient + expect(rendered).to eq('http://google.com') + end + + context "with text option" do + let(:options) { { text: "Google" } } + + it "renders a link" do + render ingredient, options: options + expect(rendered).to eq('Google') + end + end + + context "with text setting on ingredient definition" do + before do + allow(ingredient).to receive(:settings).and_return({ text: "Yahoo" }) + end + + it "renders a link" do + render ingredient + expect(rendered).to eq('Yahoo') + end + end +end diff --git a/spec/views/alchemy/ingredients/node_editor_spec.rb b/spec/views/alchemy/ingredients/node_editor_spec.rb new file mode 100644 index 0000000000..9ba3daf63b --- /dev/null +++ b/spec/views/alchemy/ingredients/node_editor_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_node_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Node.new(element: element, role: "node") } + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + end + + subject do + render element_editor + rendered + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a node select" do + is_expected.to have_css("input.alchemy_selectbox.full_width") + end + + context "with a node related to ingredient" do + let(:node) { Alchemy::Node.new(id: 1) } + let(:ingredient) { Alchemy::Ingredients::Node.new(node: node, element: element, role: "role") } + + it "sets node id as value" do + is_expected.to have_css('input.alchemy_selectbox[value="1"]') + end + end +end diff --git a/spec/views/alchemy/ingredients/node_view_spec.rb b/spec/views/alchemy/ingredients/node_view_spec.rb new file mode 100644 index 0000000000..655144af3b --- /dev/null +++ b/spec/views/alchemy/ingredients/node_view_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_node_view" do + context "without node" do + let(:ingredient) { Alchemy::Ingredients::Node.new } + + it "renders nothing" do + render ingredient + expect(rendered.strip).to be_empty + end + end + + context "with node" do + let(:node) { build(:alchemy_node, url: "https://example.com") } + let(:ingredient) { Alchemy::Ingredients::Node.new(node: node) } + + it "renders the node" do + render ingredient + expect(rendered).to have_selector("a[href='https://example.com']") + end + end +end diff --git a/spec/views/alchemy/ingredients/page_editor_spec.rb b/spec/views/alchemy/ingredients/page_editor_spec.rb new file mode 100644 index 0000000000..fc3ee32798 --- /dev/null +++ b/spec/views/alchemy/ingredients/page_editor_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_page_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Page.new(element: element, role: "page") } + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + end + + subject do + render element_editor + rendered + end + + it_behaves_like "an alchemy ingredient editor" + + it "renders a page input" do + is_expected.to have_css("input.alchemy_selectbox.full_width") + end + + context "with a page related to ingredient" do + let(:page) { Alchemy::Page.new(id: 1) } + let(:ingredient) { Alchemy::Ingredients::Page.new(page: page, element: element, role: "role") } + + it "sets page id as value" do + is_expected.to have_css('input.alchemy_selectbox[value="1"]') + end + end +end diff --git a/spec/views/alchemy/ingredients/page_view_spec.rb b/spec/views/alchemy/ingredients/page_view_spec.rb new file mode 100644 index 0000000000..c9882a46cd --- /dev/null +++ b/spec/views/alchemy/ingredients/page_view_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_page_view" do + let(:page) { build(:alchemy_page, urlname: "a-page") } + let(:ingredient) { Alchemy::Ingredients::Page.new(page: page) } + + context "without page" do + let(:ingredient) { Alchemy::Ingredients::Page.new } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end + + context "with page" do + it "renders a link to the page" do + render ingredient + expect(rendered).to have_selector("a[href='/#{page.urlname}']") + end + + it "has the page name as link text" do + render ingredient + expect(rendered).to have_selector("a:contains('#{page.name}')") + end + end +end diff --git a/spec/views/alchemy/ingredients/picture_editor_spec.rb b/spec/views/alchemy/ingredients/picture_editor_spec.rb new file mode 100644 index 0000000000..fab4eb42b1 --- /dev/null +++ b/spec/views/alchemy/ingredients/picture_editor_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_picture_editor" do + let(:picture) { stub_model(Alchemy::Picture) } + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Picture, + caption: "This is a cute cat", + element: element, + picture: picture, + role: "image", + ) + end + + let(:settings) { {} } + + it_behaves_like "an alchemy ingredient editor" + + before do + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + allow(ingredient).to receive(:settings) { settings } + end + + subject do + render element_editor + rendered + end + + context "with settings[:deletable] being nil" do + it "should not render a button to link and unlink the picture" do + is_expected.to have_selector("a .icon.fa-link") + is_expected.to have_selector("a .icon.fa-unlink") + end + end + + context "with settings[:linkable] being false" do + let(:settings) do + { + linkable: false, + } + end + + it "should not render a button to link and unlink the picture" do + is_expected.to_not have_selector("a .icon.fa-link") + is_expected.to_not have_selector("a .icon.fa-unlink") + end + + it "but renders the disabled link and unlink icons" do + is_expected.to have_selector(".icon.fa-link") + is_expected.to have_selector(".icon.fa-unlink") + end + end + + context "with image cropping enabled" do + before do + allow(ingredient).to receive(:allow_image_cropping?) { true } + end + + it "shows cropping link" do + is_expected.to have_selector('a[href*="crop"]') + end + end + + context "with image cropping disabled" do + before do + allow(ingredient).to receive(:allow_image_cropping?) { false } + end + + it "shows disabled cropping link" do + is_expected.to have_selector("a.disabled .icon.fa-crop") + end + end +end diff --git a/spec/views/alchemy/ingredients/picture_view_spec.rb b/spec/views/alchemy/ingredients/picture_view_spec.rb new file mode 100644 index 0000000000..20c156a799 --- /dev/null +++ b/spec/views/alchemy/ingredients/picture_view_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_picture_view" do + let(:picture) { stub_model(Alchemy::Picture) } + let(:element) { build(:alchemy_element) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Picture, + caption: "This is a cute cat", + element: element, + picture: picture, + role: "image", + ) + end + + before do + expect_any_instance_of(Alchemy::PictureView).to receive(:render).and_call_original + end + + it "renders an image tag" do + render ingredient + expect(rendered).to have_css("img") + end +end diff --git a/spec/views/alchemy/ingredients/richtext_editor_spec.rb b/spec/views/alchemy/ingredients/richtext_editor_spec.rb new file mode 100644 index 0000000000..c11f524f44 --- /dev/null +++ b/spec/views/alchemy/ingredients/richtext_editor_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_richtext_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Richtext.new(role: "text", value: "

    1234

    ", element: element) } + let(:settings) { {} } + + it_behaves_like "an alchemy ingredient editor" + + before do + view.class.send :include, Alchemy::Admin::BaseHelper + view.class.send :include, Alchemy::Admin::IngredientsHelper + allow(ingredient).to receive(:settings) { settings } + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + render element_editor + end + + it "renders a text area for tinymce" do + expect(rendered).to have_selector(".tinymce_container textarea#tinymce_#{ingredient.id}.has_tinymce") + end +end diff --git a/spec/views/alchemy/ingredients/richtext_view_spec.rb b/spec/views/alchemy/ingredients/richtext_view_spec.rb new file mode 100644 index 0000000000..2265e79fc0 --- /dev/null +++ b/spec/views/alchemy/ingredients/richtext_view_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_richtext_view" do + let(:element) { build(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:ingredient) { Alchemy::Ingredients::Richtext.new(role: "richtext", value: "

    Lorem ipsum dolor sit amet

    consectetur adipiscing elit.

    ", data: { stripped_body: "Lorem ipsum dolor sit amet consectetur adipiscing elit." }, element: element) } + let(:options) { {} } + + subject do + render ingredient, options: options + rendered + end + + it "renders the html body" do + is_expected.to have_content("Lorem ipsum dolor sit amet consectetur adipiscing elit.") + is_expected.to have_selector("h1") + end + + context "with options[:plain_text] true" do + let(:options) { { plain_text: true } } + + it "renders the plain text body" do + is_expected.to have_content("Lorem ipsum dolor sit amet consectetur adipiscing elit.") + is_expected.to_not have_selector("h1") + end + end + + context "with ingredient.settings[:plain_text] true" do + before do + allow(ingredient).to receive(:settings).and_return({ plain_text: true }) + end + + it "renders the text body" do + is_expected.to have_content("Lorem ipsum dolor sit amet consectetur adipiscing elit.") + is_expected.to_not have_selector("h1") + end + end +end diff --git a/spec/views/alchemy/ingredients/select_editor_spec.rb b/spec/views/alchemy/ingredients/select_editor_spec.rb new file mode 100644 index 0000000000..6eaf604b5e --- /dev/null +++ b/spec/views/alchemy/ingredients/select_editor_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_select_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Select.new(role: "select", value: "blue", element: element) } + + it_behaves_like "an alchemy ingredient editor" + + before do + view.class.send :include, Alchemy::Admin::BaseHelper + view.class.send :include, Alchemy::Admin::IngredientsHelper + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + end + + subject do + render element_editor + rendered + end + + context "if no select values are set" do + before do + expect(ingredient).to receive(:settings).at_least(:once) do + { + select_values: nil, + } + end + end + + it "renders a warning" do + is_expected.to have_css(".warning") + end + end + + context "if select values are set" do + it "renders a select box" do + is_expected.to have_css("select.alchemy_selectbox") + end + end +end diff --git a/spec/views/alchemy/ingredients/select_view_spec.rb b/spec/views/alchemy/ingredients/select_view_spec.rb new file mode 100644 index 0000000000..a15b75c25f --- /dev/null +++ b/spec/views/alchemy/ingredients/select_view_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_select_view" do + let(:ingredient) { Alchemy::Ingredients::Select.new(value: "blue") } + + it "renders the ingredient" do + render ingredient + expect(rendered).to have_content("blue") + end +end diff --git a/spec/views/alchemy/ingredients/text_editor_spec.rb b/spec/views/alchemy/ingredients/text_editor_spec.rb new file mode 100644 index 0000000000..8b2640bbc5 --- /dev/null +++ b/spec/views/alchemy/ingredients/text_editor_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_text_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:ingredient) { Alchemy::Ingredients::Text.new(role: "headline", value: "1234", element: element) } + let(:settings) { {} } + + it_behaves_like "an alchemy ingredient editor" + + before do + view.class.send :include, Alchemy::Admin::BaseHelper + view.class.send :include, Alchemy::Admin::IngredientsHelper + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + allow(ingredient).to receive(:settings) { settings } + render element_editor + end + + context "with no input type set" do + it "renders an input field of type number" do + expect(rendered).to have_selector('input[type="text"]') + end + end + + context "with a different input type set" do + let(:settings) do + { + input_type: "number", + } + end + + it "renders an input field of type number" do + expect(rendered).to have_selector('input[type="number"]') + end + end + + context "with settings linkable set to true" do + let(:settings) do + { + linkable: true, + } + end + + it "renders link buttons" do + expect(rendered).to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link]"]') + expect(rendered).to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_title]"]') + expect(rendered).to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_class_name]"]') + expect(rendered).to have_selector('input[type="hidden"][name="element[ingredients_attributes][0][link_target]"]') + end + end +end diff --git a/spec/views/alchemy/ingredients/text_view_spec.rb b/spec/views/alchemy/ingredients/text_view_spec.rb new file mode 100644 index 0000000000..ac37f155b6 --- /dev/null +++ b/spec/views/alchemy/ingredients/text_view_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "alchemy/ingredients/_text_view" do + let(:ingredient) { Alchemy::Ingredients::Text.new(value: "Hello World") } + + context "with blank link value" do + it "only renders the value" do + render ingredient + expect(rendered).to have_content("Hello World") + expect(rendered).to_not have_selector("a") + end + end + + context "with a link set" do + let(:ingredient) do + Alchemy::Ingredients::Text.new( + value: "Hello World", + data: { + link: "http://google.com", + link_title: "Foo", + link_target: "blank", + }, + ) + end + + it "renders the linked value" do + render ingredient + expect(rendered).to have_content("Hello World") + expect(rendered).to have_selector('a[title="Foo"][target="_blank"][data-link-target="blank"][href="http://google.com"]') + end + + context "with html_options given" do + it "renders the linked with these options" do + render ingredient, html_options: { title: "Bar", class: "blue" } + expect(rendered).to have_selector('a.blue[title="Bar"][target="_blank"][data-link-target="blank"]') + end + end + + context "but with options disable_link set to true" do + it "only renders the value" do + render ingredient, options: { disable_link: true } + expect(rendered).to have_content("Hello World") + expect(rendered).to_not have_selector("a") + end + end + + context "but with ingredient settings disable_link set to true" do + before do + allow(ingredient).to receive(:settings).and_return({ disable_link: true }) + end + + it "only renders the value" do + render ingredient + expect(rendered).to have_content("Hello World") + expect(rendered).to_not have_selector("a") + end + end + end +end diff --git a/spec/views/alchemy/ingredients/video_editor_spec.rb b/spec/views/alchemy/ingredients/video_editor_spec.rb new file mode 100644 index 0000000000..8d564d492d --- /dev/null +++ b/spec/views/alchemy/ingredients/video_editor_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_video_editor" do + let(:element) { build_stubbed(:alchemy_element, name: "all_you_can_eat_ingredients") } + let(:element_editor) { Alchemy::ElementEditor.new(element) } + let(:attachment) { build_stubbed(:alchemy_attachment) } + + let(:ingredient) do + stub_model( + Alchemy::Ingredients::Audio, + element: element, + attachment: attachment, + role: "file", + ) + end + + let(:video_editor) { Alchemy::IngredientEditor.new(ingredient) } + let(:settings) { {} } + + subject do + render element_editor + rendered + end + + before do + view.class.send(:include, Alchemy::Admin::IngredientsHelper) + allow(ingredient).to receive(:settings) { settings } + allow(video_editor).to receive(:attachment) { attachment } + allow(element_editor).to receive(:ingredients) { [Alchemy::IngredientEditor.new(ingredient)] } + end + + it_behaves_like "an alchemy ingredient editor" + + context "with attachment present" do + it "renders a hidden field with attachment id" do + is_expected.to have_selector("input[type='hidden'][value='#{attachment.id}']") + end + + it "renders a link to open the attachment library overlay" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id']") + end + end + + it "renders a link to edit the ingredient" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/ingredients/#{ingredient.id}/edit']") + end + end + + context "with settings `only`" do + let(:settings) { { only: "mov" } } + + it "renders a link to open the attachment library overlay with only movs" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&only=mov']") + end + end + end + + context "with settings `except`" do + let(:settings) { { except: "mov" } } + + it "renders a link to open the attachment library overlay without movs" do + within ".file_tools" do + is_expected.to have_selector("a[href='/admin/attachments?form_field_id=element_ingredients_attributes_0_attachment_id&except=mov']") + end + end + end + end + + context "without attachment present" do + let(:attachment) { nil } + + it "renders a hidden field for attachment_id" do + is_expected.to have_selector("input[type='hidden'][name='element[ingredients_attributes][0][attachment_id]']") + end + end +end diff --git a/spec/views/alchemy/ingredients/video_view_spec.rb b/spec/views/alchemy/ingredients/video_view_spec.rb new file mode 100644 index 0000000000..8226bf7970 --- /dev/null +++ b/spec/views/alchemy/ingredients/video_view_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "alchemy/ingredients/_video_view" do + let(:file) do + File.new(File.expand_path("../../../fixtures/image with spaces.png", __dir__)) + end + + let(:attachment) do + build_stubbed(:alchemy_attachment, file: file, name: "a movie", file_name: "image with spaces.png") + end + + let(:ingredient) do + Alchemy::Ingredients::Video.new( + role: "video", + attachment: attachment, + allow_fullscreen: true, + autoplay: true, + controls: true, + height: 720, + loop: true, + muted: true, + preload: "auto", + width: 1280, + ) + end + + context "without attachment" do + let(:ingredient) { Alchemy::Ingredients::Video.new(attachment: nil) } + + it "renders nothing" do + render ingredient + expect(rendered).to eq("") + end + end + + context "with attachment" do + it "renders a video tag with source" do + render ingredient + expect(rendered).to have_selector( + "video[controls][muted][loop][autoplay][preload='auto'][width='1280'][height='720'] source[src]" + ) + end + end +end