diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ad6e23647f..477cadb482 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -122,4 +122,4 @@ jobs:
- name: Install yarn
run: yarn install
- name: Run jest
- run: yarn jest
+ run: yarn jest --verbose
diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js
index a24f57bb87..0d12b3efcd 100644
--- a/app/assets/javascripts/alchemy/admin.js
+++ b/app/assets/javascripts/alchemy/admin.js
@@ -13,7 +13,6 @@
//= require alchemy/alchemy.dialog
//= require alchemy/alchemy.confirm_dialog
//= require alchemy/alchemy.dragndrop
-//= require alchemy/alchemy.element_editors
//= require alchemy/alchemy.elements_window
//= require alchemy/alchemy.fixed_elements
//= require alchemy/alchemy.growler
diff --git a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee b/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee
deleted file mode 100644
index 0f42efa547..0000000000
--- a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee
+++ /dev/null
@@ -1,267 +0,0 @@
-window.Alchemy = {} if typeof(window.Alchemy) is 'undefined'
-
-# Handlers for element editors.
-#
-# It provides folding of element editors and
-# selecting element editors from the preview frame
-# and the elenents window.
-#
-Alchemy.ElementEditors =
-
- # Binds all events to element editor partials.
- #
- # Calles once per page load.
- #
- init: ->
- @element_area = $("#element_area")
- @bindEvents()
- @expandIngredientGroups()
- return
-
- # Binds click events on several DOM elements from element editors
- # Uses event delegation, so it is not necessary to rebind these events.
- bindEvents: ->
- $('body').on 'click', (e) =>
- @onClickBody(e)
- @element_area.on "click", ".element-editor", (e) =>
- @onClickElement(e)
- @element_area.on "dblclick", ".element-header", (e) =>
- @onDoubleClickElement(e)
- @element_area.on "click", "[data-element-toggle]", (e) =>
- @onClickToggle(e)
- # Binds the custom FocusElementEditor event
- @element_area.on "FocusElementEditor.Alchemy", '.element-editor', (e) =>
- @onFocusElement(e)
- # Binds the custom SaveElement event
- @element_area.on "SaveElement.Alchemy", '.element-editor', (e, data) =>
- @onSaveElement(e, data)
- @element_area.on "click", '[data-toggle-ingredient-group]', (e) =>
- @onToggleIngredientGroup(e)
- # Listen to postMessage messages from the preview frame
- window.addEventListener 'message', (e) =>
- @onMessage(e.data)
- true
- return
-
- # Expands ingredient groups that are stored in sessionStorage as expanded
- expandIngredientGroups: ->
- if $expanded_ingredient_groups = sessionStorage.getItem('Alchemy.expanded_ingredient_groups')
- for header_id in JSON.parse($expanded_ingredient_groups) then do (header_id) =>
- $('#' + header_id).closest('.ingredient-group').addClass('expanded');
-
- # Selects and scrolls to element with given id in the preview window.
- #
- focusElementPreview: (element_id) ->
- Alchemy.PreviewWindow.postMessage
- message: 'Alchemy.focusElement'
- element_id: element_id
- return
-
- # Selects element
- # Scrolls to element
- # Unfold if folded
- # Also chooses the right fixed elements tab, if necessary.
- # Can be triggered through custom event 'FocusElementEditor.Alchemy'
- # Used by the elements on click events in the preview frame.
- focusElement: ($element) ->
- element_id = $element.attr('id').replace(/\D/g, "")
- Alchemy.ElementsWindow.show()
- @selectTabForElement($element)
- # If we have folded parents we need to unfold each of them
- # and then finally scroll to or unfold ourself
- $folded_parents = $element.parents('.element-editor.folded')
- if $folded_parents.length > 0
- @unfoldParents $folded_parents, =>
- @scrollToOrUnfold(element_id)
- return
- else
- @scrollToOrUnfold(element_id)
- return
-
- # Selects tab for given element
- selectTabForElement: ($element) ->
- tabs = document.querySelector("#fixed-elements")
- if tabs
- panel = $element.closest("sl-tab-panel").attr("name")
- tabs.show(panel)
- return
-
- # Marks an element as selected in the element window and scrolls to it.
- #
- selectElement: ($element, scroll = false) ->
- $("#element_area .element-editor").not($element[0]).removeClass("selected")
- $element.addClass("selected")
- @scrollToElement($element) if scroll
- return
-
- # Unfolds given parents until the last one is reached, then calls callback
- unfoldParents: ($folded_parents, callback) ->
- last_parent = $folded_parents[$folded_parents.length - 1]
- $folded_parents.each (_index, parent_element) =>
- parent_id = parent_element.id.replace(/\D/g, "")
- if last_parent == parent_element
- @scrollToOrUnfold(parent_id, callback)
- else
- @scrollToOrUnfold(parent_id)
- return
- return
-
- # Scrolls to element with given id
- #
- # If it's folded it unfolds it.
- #
- # Also takes an optional callback that gets triggered after element is unfolded.
- #
- scrollToOrUnfold: (element_id, callback) ->
- $el = $("#element_#{element_id}")
- if $el.hasClass("folded")
- @toggleFold(element_id, callback)
- else
- @selectElement($el, true)
- return
-
- # Scrolls the element window to given element editor dom element.
- #
- scrollToElement: (el) ->
- $("#element_area").scrollTo el,
- axis: 'y',
- duration: 400
- offset: -6
-
- # Expands or folds a element editor
- #
- # If the element is dirty (has unsaved changes) it displays a warning.
- #
- toggle: (id, text) ->
- el = $("#element_#{id}")
- if Alchemy.isElementDirty(el)
- Alchemy.openConfirmDialog Alchemy.t('element_dirty_notice'),
- title: Alchemy.t('warning')
- ok_label: Alchemy.t('ok')
- cancel_label: Alchemy.t('cancel')
- on_ok: =>
- @toggleFold(id)
- false
- else
- @toggleFold(id)
-
- # Folds or expands the element editor with the given id.
- #
- toggleFold: (id, callback) ->
- spinner = new Alchemy.Spinner('small')
- spinner.spin($("#element_#{id} > .element-header .ajax-folder"))
- $("#element_#{id}_folder .icon").hide()
- $.post Alchemy.routes.fold_admin_element_path(id), =>
- callback.call() if callback?
- return
-
- # Updates the title quote if one of the several conditions are met
- updateTitle: (element, title, event) ->
- return true if not @_shouldUpdateTitle(element, event)
- @setTitle(element, title)
- return
-
- # Sets the title quote without checking that the conditions are met
- setTitle: (element, title) ->
- $quote = element.find('> .element-header .preview_text_quote')
- $quote.text(title)
- return
-
- # Sets the element to saved state
- onSaveElement: (event, data) ->
- $element = $(event.currentTarget)
- # JS event bubbling will also update the parents element quote.
- @updateTitle($element, data.previewText, event)
- # Prevent this event from beeing called twice on the same element
- if event.currentTarget == event.target
- Alchemy.setElementClean($element)
- true
-
- # Toggle visibility of the ingredient fields in the group
- onToggleIngredientGroup: (event) ->
- $group_div = $(event.currentTarget).closest('.ingredient-group');
- $group_div.toggleClass('expanded');
-
- $expanded_ingredient_groups = JSON.parse(sessionStorage.getItem('Alchemy.expanded_ingredient_groups') || '[]');
- # Add or remove depending on whether this ingredient group is expanded
- if $group_div.hasClass('expanded')
- if $expanded_ingredient_groups.indexOf(event.currentTarget.id) == -1
- $expanded_ingredient_groups.push(event.currentTarget.id);
- else
- $expanded_ingredient_groups = $expanded_ingredient_groups.filter (value) ->
- value != event.currentTarget.id
-
- sessionStorage.setItem('Alchemy.expanded_ingredient_groups', JSON.stringify($expanded_ingredient_groups))
- false
-
- # Event handlers
-
- onMessage: (data) ->
- if data.message == 'Alchemy.focusElementEditor'
- $element = $("#element_#{data.element_id}")
- Alchemy.ElementEditors.focusElement($element)
-
- onClickBody: (e) ->
- element = $(e.target).parents('.element-editor')[0]
- $('#element_area .element-editor').not(element).removeClass('selected')
- unless element
- Alchemy.PreviewWindow.postMessage(message: 'Alchemy.blurElements')
- return
-
- # Click event handler for element body.
- #
- # - Focuses the element
- # - Sends 'Alchemy.focusElement' message to preview frame.
- #
- onClickElement: (e) ->
- $target = $(e.target)
- $element = $target.closest(".element-editor")
- element_id = $element.attr("id").replace(/\D/g, "")
- @selectElement($element)
- @focusElementPreview(element_id)
- return
-
- # Double click event handler for element head.
- onDoubleClickElement: (e) ->
- id = $(e.target).closest('.element-editor').attr('id').replace(/\D/g, '')
- @toggle(id)
- e.preventDefault()
- return
-
- # Click event handler for element toggle icon.
- onClickToggle: (e) ->
- id = $(e.currentTarget).data('element-toggle')
- @toggle(id)
- e.preventDefault()
- e.stopPropagation()
- return
-
- # Handles the custom 'FocusElementEditor.Alchemy' event.
- #
- # Triggered, if a user clicks on an element inside the preview iframe.
- #
- onFocusElement: (e) ->
- $element = $(e.target)
- @focusElement($element)
- e.stopPropagation()
- false
-
- # private
-
- _shouldUpdateTitle: (element, event) ->
- editors = element.find('> .element-body .element-ingredient-editors').children()
- if @_hasParents(element)
- editors.length != 0
- else if @_isParent(element) && @_isFirstChild $(event.target)
- editors.length == 0
- else
- not @_isParent(element)
-
- _hasParents: (element) ->
- element.parents('.element-editor').length != 0
-
- _isParent: (element) ->
- element.find('.nestable-elements').length != 0
-
- _isFirstChild: (element) ->
- element.closest('.nestable-elements').find(':first-child').is(element)
diff --git a/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee b/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee
index a108a5957a..55c02cc5c1 100644
--- a/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee
+++ b/app/assets/javascripts/alchemy/alchemy.elements_window.js.coffee
@@ -3,6 +3,8 @@ window.Alchemy = {} if typeof(window.Alchemy) is 'undefined'
# Adds buttons into a toolbar inside of overlay windows
Alchemy.ToolbarButton = (options) ->
$btn = $('
')
+ if options.align
+ $btn.addClass(options.align)
if options.buttonId
$btn.attr(id: options.buttonId)
$lnk = $("")
@@ -14,7 +16,7 @@ Alchemy.ToolbarButton = (options) ->
return
$lnk.append ""
$btn.append $lnk
- $btn.append "
"
+ $btn.append "
"
$btn
Alchemy.ElementsWindow =
@@ -33,25 +35,45 @@ Alchemy.ElementsWindow =
@button.click =>
@hide()
false
+
window.requestAnimationFrame =>
spinner = new Alchemy.Spinner('medium')
spinner.spin @element_area[0]
+
+ window.addEventListener 'message', (event) =>
+ data = event.data
+ if data?.message == 'Alchemy.focusElementEditor'
+ element = document.getElementById("element_#{data.element_id}")
+ Alchemy.ElementsWindow.show()
+ element?.focusElement()
+ true
+
+ @$body.on "click", (evt) =>
+ unless evt.target.closest(".element-editor")
+ @element_area.find('.element-editor').removeClass('selected')
+ Alchemy.PreviewWindow.postMessage(message: 'Alchemy.blurElements')
+ return
+
$('#main_content').append(@element_window)
@show()
@reload()
createToolbar: (buttons) ->
- @toolbar = $('')
+ @toolbar = $('')
+ buttons.push
+ label: "Collapse all elements"
+ iconClass: "compress-alt"
+ align: "right"
+ onClick: =>
+ $("alchemy-element-editor:not([compact]):not([fixed])").each () ->
+ @collapse()
for btn in buttons
@toolbar.append Alchemy.ToolbarButton(btn)
- @toolbar
+ @toolbar.append @collapseAllBtn
reload: ->
$.get @url, (data) =>
@element_area.html data
- Alchemy.GUI.init(@element_area)
- Alchemy.fileEditors(@element_area.find(".ingredient-editor.file, .ingredient-editor.audio, .ingredient-editor.video").selector)
- Alchemy.pictureEditors(@element_area.find(".ingredient-editor.picture").selector)
if @callback
@callback.call()
.fail (xhr, status, error) =>
diff --git a/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee
index ea02fbe3f3..5a3351c3f2 100644
--- a/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee
+++ b/app/assets/javascripts/alchemy/alchemy.link_dialog.js.coffee
@@ -136,7 +136,8 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog
# Sets the link either in TinyMCE or on an Ingredient.
setLink: (url, title, target) ->
- Alchemy.setElementDirty(@$link_object.closest('.element-editor'))
+ element_editor = @$link_object[0].closest('alchemy-element-editor')
+ element_editor.setDirty()
if @link_object.editor
@setTinyMCELink(url, title, target)
else
@@ -262,7 +263,8 @@ class window.Alchemy.LinkDialog extends Alchemy.Dialog
link_class_field.value = ""
link_target_field.value = ""
if link.classList.contains('linked')
- Alchemy.setElementDirty link.closest('.element-editor')
+ element_editor = link.closest('alchemy-element-editor')
+ element_editor.setDirty()
link.classList.replace('linked', 'disabled')
link.setAttribute('tabindex', '-1')
link.blur()
diff --git a/app/assets/javascripts/alchemy/alchemy.preview.js.coffee b/app/assets/javascripts/alchemy/alchemy.preview.js.coffee
index 26f59935bd..07b055a0f3 100644
--- a/app/assets/javascripts/alchemy/alchemy.preview.js.coffee
+++ b/app/assets/javascripts/alchemy/alchemy.preview.js.coffee
@@ -48,7 +48,7 @@ Alchemy.initAlchemyPreviewMode = ->
# Mark element in preview frame as selected and scrolls to it.
selectElement: (element) ->
- @blurElements()
+ @blurElements(element)
element.classList.add('selected')
Object.assign element.style, @getStyle('selected')
element.scrollIntoView
@@ -57,10 +57,11 @@ Alchemy.initAlchemyPreviewMode = ->
return
# Blur all elements in preview frame.
- blurElements: ->
+ blurElements: (selectedElement) ->
@elements.forEach (element) =>
- element.classList.remove('selected')
- Object.assign element.style, @getStyle('reset')
+ if element != selectedElement
+ element.classList.remove('selected')
+ Object.assign element.style, @getStyle('reset')
return
return
diff --git a/app/assets/stylesheets/alchemy/elements.scss b/app/assets/stylesheets/alchemy/elements.scss
index e15183e483..9b11f5638c 100644
--- a/app/assets/stylesheets/alchemy/elements.scss
+++ b/app/assets/stylesheets/alchemy/elements.scss
@@ -25,6 +25,17 @@
}
}
+.elements-window-toolbar {
+ @extend %gradiated-toolbar;
+ display: flex;
+ padding: 2 * $default-padding;
+ border-bottom: $default-border;
+
+ .right {
+ margin-left: auto;
+ }
+}
+
#element_area {
height: calc(100vh - #{$top-menu-height + $toolbar-height});
overflow-x: hidden;
@@ -37,11 +48,6 @@
textarea {
width: 100%;
-
- &.has_tinymce {
- // We need to do this, because globally all texareas have height: auto !important
- height: 140px !important;
- }
}
> .message {
@@ -49,6 +55,10 @@
}
}
+alchemy-tinymce {
+ display: block;
+}
+
#main-content-elements,
.element-editor.is-fixed .nestable-elements {
padding: 2 * $default-padding $default-padding 2px;
@@ -100,22 +110,36 @@
cursor: move;
}
-.ajax-folder {
+.element-toggle {
+ display: flex;
+ align-items: center;
position: absolute;
- width: 16px;
- height: 16px;
+ width: 20px;
+ height: 20px;
right: 8px;
- top: 10px;
+ top: 8px;
transition: none;
+ background: none;
+ border-color: transparent;
+ color: inherit;
+ box-shadow: none;
+ padding: 4px;
+ margin: 0;
- .error_icon {
- float: left;
- width: 14px;
- height: 15px;
- text-align: center;
- background-color: white;
- border: 1px solid #935b5b;
- color: #935b5b;
+ &:hover {
+ &:not(:focus):not(:active) {
+ background-color: $default-border-color;
+ border-color: transparent;
+ cursor: pointer;
+ }
+ }
+
+ .icon {
+ pointer-events: none;
+
+ &.hidden {
+ display: none;
+ }
}
}
@@ -124,8 +148,13 @@
border: 1px solid $default-border-color;
border-radius: $default-border-radius;
background-color: $light-gray;
- margin-bottom: 2 * $default-margin;
+ margin: 2 * $default-margin 0;
transition: box-shadow $transition-duration;
+ scroll-margin: 2 * $default-margin;
+
+ &:first-child {
+ margin-top: 0;
+ }
&.hidden {
display: block;
@@ -206,6 +235,10 @@
}
&.folded {
+ .element-toolbar,
+ .element-body,
+ .element-footer,
+ .nestable-elements,
.nested-elements {
display: none;
}
@@ -269,7 +302,7 @@
max-width: 75%;
}
- &:not(.folded) .ajax-folder {
+ &:not(.folded) .element-toggle {
pointer-events: none;
i:before {
@@ -430,7 +463,7 @@
padding-bottom: 0;
}
- .ingredient-group-header {
+ summary {
display: flex;
align-items: center;
justify-content: space-between;
@@ -439,18 +472,14 @@
padding: $default-padding 1px;
}
- .ingredient-group-ingredients {
- display: none;
- }
-
- &.expanded {
- .ingredient-group-ingredients {
- display: block;
- }
-
+ &[open] {
.ingredient-group-expand {
@extend .fa-angle-up;
}
+
+ > :not(summary) {
+ box-sizing: border-box;
+ }
}
}
@@ -885,7 +914,6 @@ textarea.has_tinymce {
}
.element_errors {
- display: none;
margin-top: 8px;
margin-bottom: 8px;
background-color: $error_background_color;
@@ -895,6 +923,10 @@ textarea.has_tinymce {
color: $error_text_color;
border: 1px solid $error_border_color;
+ &.hidden {
+ display: none;
+ }
+
p {
margin: 0;
line-height: 24px;
@@ -932,6 +964,7 @@ textarea.has_tinymce {
.element-editor,
.droppable_element_placeholder {
+ display: block;
width: 100%;
.not-fixed & {
diff --git a/app/controllers/alchemy/admin/elements_controller.rb b/app/controllers/alchemy/admin/elements_controller.rb
index ca6dbcef28..ec88b5430f 100644
--- a/app/controllers/alchemy/admin/elements_controller.rb
+++ b/app/controllers/alchemy/admin/elements_controller.rb
@@ -3,7 +3,7 @@
module Alchemy
module Admin
class ElementsController < Alchemy::Admin::BaseController
- before_action :load_element, only: [:update, :destroy, :fold, :publish]
+ before_action :load_element, only: [:update, :destroy, :collapse, :expand, :publish]
authorize_resource class: Alchemy::Element
def index
@@ -53,13 +53,25 @@ def create
# Updates the element and all ingredients in the element.
#
def update
- @page = @element.page
-
if @element.update(element_params)
- @element_validated = true
+ render json: {
+ notice: Alchemy.t(:element_saved),
+ previewText: Rails::Html::SafeListSanitizer.new.sanitize(@element.preview_text),
+ ingredientAnchors: @element.ingredients.select { |i| i.settings[:anchor] }.map do |ingredient|
+ {
+ ingredientId: ingredient.id,
+ active: ingredient.dom_id.present?
+ }
+ end
+ }
else
- element_update_error
- @error_messages = @element.ingredient_error_messages
+ @warning = Alchemy.t("Validation failed")
+ render json: {
+ warning: @warning,
+ errorMessage: Alchemy.t(:ingredient_validations_headline),
+ ingredientsWithErrors: @element.ingredients_with_errors.map(&:id),
+ errors: @element.ingredient_error_messages
+ }
end
end
@@ -90,17 +102,35 @@ def order
end
end
- # Toggle folds the element and persists the state in the db
+ # Collapses the element, all nested elements and persists the state in the db
#
- def fold
- @page = @element.page
+ def collapse
# We do not want to trigger the touch callback or any validations
- @element.update_columns(folded: !@element.folded)
- # Fold all nested elements if folded
- if @element.folded?
- ids = collapse_nested_elements_ids(@element)
- Alchemy::Element.where(id: ids).update_all(folded: true)
- end
+ @element.update_columns(folded: true)
+ # Collapse all nested elements
+ nested_elements_ids = collapse_nested_elements_ids(@element)
+ Alchemy::Element.where(id: nested_elements_ids).update_all(folded: true)
+
+ render json: {
+ nestedElementIds: nested_elements_ids,
+ title: Alchemy.t(@element.folded? ? :show_element_content : :hide_element_content)
+ }
+ end
+
+ # Expands the element, all parents and persists the state in the db
+ #
+ def expand
+ # We do not want to trigger the touch callback or any validations
+ @element.update_columns(folded: false)
+ # We want to expand the upper most parent first in order to prevent
+ # re-painting issues in the browser
+ parent_element_ids = @element.parent_element_ids.reverse
+ Alchemy::Element.where(id: parent_element_ids).update_all(folded: false)
+
+ render json: {
+ parentElementIds: parent_element_ids,
+ title: Alchemy.t(@element.folded? ? :show_element_content : :hide_element_content)
+ }
end
private
@@ -171,12 +201,6 @@ def element_params
def create_element_params
params.require(:element).permit(:name, :page_version_id, :parent_element_id)
end
-
- def element_update_error
- @element_validated = false
- @notice = Alchemy.t("Validation failed")
- @error_message = "#{@notice}
#{Alchemy.t(:ingredient_validations_headline)}
".html_safe
- end
end
end
end
diff --git a/app/decorators/alchemy/element_editor.rb b/app/decorators/alchemy/element_editor.rb
index b9a967fae7..4668c76136 100644
--- a/app/decorators/alchemy/element_editor.rb
+++ b/app/decorators/alchemy/element_editor.rb
@@ -65,8 +65,6 @@ def css_classes
# Tells us, if we should show the element footer and form inputs.
def editable?
- return false if folded?
-
ingredient_definitions.any? || taggable?
end
diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js
index e2f450b744..25acac0db9 100644
--- a/app/javascript/alchemy_admin.js
+++ b/app/javascript/alchemy_admin.js
@@ -5,9 +5,7 @@ import Rails from "@rails/ujs"
import GUI from "alchemy_admin/gui"
import { translate } from "alchemy_admin/i18n"
import Dirty from "alchemy_admin/dirty"
-import fileEditors from "alchemy_admin/file_editors"
import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
-import pictureEditors from "alchemy_admin/picture_editors"
import ImageLoader from "alchemy_admin/image_loader"
import ImageCropper from "alchemy_admin/image_cropper"
import Initializer from "alchemy_admin/initializer"
@@ -25,6 +23,8 @@ import "alchemy_admin/components/button"
import "alchemy_admin/components/char_counter"
import "alchemy_admin/components/datepicker"
import "alchemy_admin/components/dialog_link"
+import "alchemy_admin/components/element_editor"
+import "alchemy_admin/components/ingredient_group"
import "alchemy_admin/components/node_select"
import "alchemy_admin/components/overlay"
import "alchemy_admin/components/page_select"
@@ -46,8 +46,6 @@ Object.assign(Alchemy, {
...Dirty,
GUI,
t: translate, // Global utility method for translating a given string
- fileEditors,
- pictureEditors,
ImageLoader: ImageLoader.init,
ImageCropper,
Initializer,
diff --git a/app/javascript/alchemy_admin/components/button.js b/app/javascript/alchemy_admin/components/button.js
index 8a2c1db3a7..3196f12769 100644
--- a/app/javascript/alchemy_admin/components/button.js
+++ b/app/javascript/alchemy_admin/components/button.js
@@ -3,7 +3,19 @@ import Spinner from "../spinner"
class Button extends HTMLButtonElement {
connectedCallback() {
if (this.form) {
- this.form.addEventListener("submit", (event) => {
+ this.form.addEventListener("submit", this)
+
+ if (this.form.dataset.remote == "true") {
+ this.form.addEventListener("ajax:complete", this)
+ }
+ } else {
+ console.warn("No form for button found!", this)
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "submit":
const isDisabled = this.getAttribute("disabled") === "disabled"
if (isDisabled) {
@@ -12,15 +24,10 @@ class Button extends HTMLButtonElement {
} else {
this.disable()
}
- })
-
- if (this.form.dataset.remote == "true") {
- this.form.addEventListener("ajax:complete", () => {
- this.enable()
- })
- }
- } else {
- console.warn("No form for button found!", this)
+ break
+ case "ajax:complete":
+ this.enable()
+ break
}
}
diff --git a/app/javascript/alchemy_admin/components/datepicker.js b/app/javascript/alchemy_admin/components/datepicker.js
index df20bf6e80..63e0234abe 100644
--- a/app/javascript/alchemy_admin/components/datepicker.js
+++ b/app/javascript/alchemy_admin/components/datepicker.js
@@ -24,7 +24,7 @@ class Datepicker extends AlchemyHTMLElement {
noCalendar: this.inputType === "time",
time_24hr: translate("formats.time_24hr"),
onValueUpdate(_selectedDates, _dateStr, instance) {
- Alchemy.setElementDirty(instance.element.closest(".element-editor"))
+ instance.element.closest("alchemy-element-editor").setDirty()
}
}
diff --git a/app/javascript/alchemy_admin/components/element_editor.js b/app/javascript/alchemy_admin/components/element_editor.js
new file mode 100644
index 0000000000..aa128047ed
--- /dev/null
+++ b/app/javascript/alchemy_admin/components/element_editor.js
@@ -0,0 +1,543 @@
+import TagsAutocomplete from "alchemy_admin/tags_autocomplete"
+import ImageLoader from "alchemy_admin/image_loader"
+import fileEditors from "alchemy_admin/file_editors"
+import pictureEditors from "alchemy_admin/picture_editors"
+import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
+import { post } from "alchemy_admin/utils/ajax"
+import { createHtmlElement } from "../utils/dom_helpers"
+
+export class ElementEditor extends HTMLElement {
+ constructor() {
+ super()
+
+ // Add event listeners
+ this.addEventListener("click", this)
+ // Triggered by child elements
+ this.addEventListener("alchemy:element-update-title", this)
+ this.addEventListener("alchemy:element-dirty", this)
+ this.addEventListener("alchemy:element-clean", this)
+ // We use of @rails/ujs for Rails remote forms
+ this.addEventListener("ajax:success", this)
+ // Dirty observer
+ this.addEventListener("change", this)
+
+ this.header?.addEventListener("dblclick", () => {
+ this.toggle()
+ })
+ this.toggleButton?.addEventListener("click", (evt) => {
+ const elementEditor = evt.target.closest("alchemy-element-editor")
+ if (elementEditor === this) {
+ this.toggle()
+ }
+ })
+ }
+
+ connectedCallback() {
+ // The placeholder while be being dragged is empty.
+ if (this.classList.contains("ui-sortable-placeholder")) {
+ return
+ }
+
+ // Init GUI elements
+ ImageLoader.init(this)
+ fileEditors(
+ `#${this.id} .ingredient-editor.file, #${this.id} .ingredient-editor.audio, #${this.id} .ingredient-editor.video`
+ )
+ pictureEditors(`#${this.id} .ingredient-editor.picture`)
+ TagsAutocomplete(this)
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ const elementEditor = event.target.closest("alchemy-element-editor")
+ if (elementEditor === this) {
+ this.onClickElement()
+ }
+ break
+ case "ajax:success":
+ if (event.target === this.body) {
+ const responseJSON = event.detail[0]
+ event.stopPropagation()
+ this.onSaveElement(responseJSON)
+ }
+ break
+ case "alchemy:element-update-title":
+ if (event.target == this.firstChild) {
+ this.setTitle(event.detail.title)
+ }
+ break
+ case "alchemy:element-dirty":
+ if (event.target !== this) {
+ this.setDirty()
+ }
+ break
+ case "alchemy:element-clean":
+ if (event.target !== this) {
+ this.setClean()
+ }
+ break
+ case "change":
+ event.stopPropagation()
+ event.target.classList.add("dirty")
+ this.setDirty()
+ break
+ }
+ }
+
+ /**
+ * Scrolls to and highlights element
+ * Expands if collapsed
+ * Also chooses the right fixed elements tab, if necessary.
+ * Can be triggered through custom event 'FocusElementEditor.Alchemy'
+ * Used by the elements on click events in the preview frame.
+ */
+ async focusElement() {
+ // Select tab if necessary
+ if (document.querySelector("#fixed-elements")) {
+ await this.selectTabForElement()
+ }
+ // Expand if necessary
+ await this.expand()
+ this.selectElement(true)
+ }
+
+ focusElementPreview() {
+ Alchemy.PreviewWindow.postMessage({
+ message: "Alchemy.focusElement",
+ element_id: this.elementId
+ })
+ }
+
+ onClickElement() {
+ this.selectElement()
+ this.focusElementPreview()
+ }
+
+ /**
+ * Sets the element to saved state
+ * Updates title
+ * Shows error messages if ingredient validations fail
+ * @argument {JSON} data
+ */
+ onSaveElement(data) {
+ // JS event bubbling will also update the parents element quote.
+ this.setClean()
+ // Reset errors that might be visible from last save attempt
+ this.errorsDisplay.innerHTML = ""
+ this.body
+ .querySelectorAll(".ingredient-editor")
+ .forEach((el) => el.classList.remove("validation_failed"))
+ // If validation failed
+ if (data.errors) {
+ const warning = data.warning
+ // Create error messages
+ data.errors.forEach((message) => {
+ this.errorsDisplay.append(createHtmlElement(`${message}`))
+ })
+ // Mark ingredients as failed
+ data.ingredientsWithErrors.forEach((id) => {
+ this.querySelector(`[data-ingredient-id="${id}"]`)?.classList.add(
+ "validation_failed"
+ )
+ })
+ // Show message
+ Alchemy.growl(warning, "warn")
+ this.elementErrors.classList.remove("hidden")
+ } else {
+ Alchemy.growl(data.notice)
+ Alchemy.PreviewWindow.refresh(() => this.focusElementPreview())
+ this.updateTitle(data.previewText)
+ data.ingredientAnchors.forEach((anchor) => {
+ IngredientAnchorLink.updateIcon(anchor.ingredientId, anchor.active)
+ })
+ }
+ }
+
+ /**
+ * Smoothly scrolls to element
+ */
+ scrollToElement() {
+ // The timeout gives the browser some time to calculate the position
+ // of nested elements correctly
+ setTimeout(() => {
+ this.scrollIntoView({
+ behavior: "smooth"
+ })
+ }, 50)
+ }
+
+ /**
+ * Highlight element and optionally scroll into view
+ * @param {boolean} scroll smoothly scroll element into view. Default (false)
+ */
+ selectElement(scroll = false) {
+ document
+ .querySelectorAll("alchemy-element-editor.selected")
+ .forEach((el) => {
+ el.classList.remove("selected")
+ })
+ window.requestAnimationFrame(() => {
+ this.classList.add("selected")
+ })
+ if (scroll) this.scrollToElement()
+ }
+
+ /**
+ * Selects tab for given element
+ * Resolves the promise if this is done.
+ * @returns {Promise}
+ */
+ selectTabForElement() {
+ return new Promise((resolve, reject) => {
+ const tabs = document.querySelector("#fixed-elements")
+ const panel = this.closest("sl-tab-panel")
+ if (tabs && panel) {
+ tabs.show(panel.getAttribute("name"))
+ resolve()
+ } else {
+ reject(new Error("No tabs present"))
+ }
+ })
+ }
+
+ /**
+ * Sets the element into clean (safed) state
+ * Dispatches alchemy:element-clean event
+ */
+ setClean() {
+ this.dirty = false
+ window.onbeforeunload = null
+ this.dispatchEvent(
+ new CustomEvent("alchemy:element-clean", { bubbles: true })
+ )
+ if (this.hasEditors) {
+ this.body.querySelectorAll(".dirty").forEach((el) => {
+ el.classList.remove("dirty")
+ })
+ }
+ }
+
+ /**
+ * Sets the element into dirty (unsafed) state
+ * Dispatches alchemy:element-dirty event
+ */
+ setDirty() {
+ this.dirty = true
+ this.dispatchEvent(
+ new CustomEvent("alchemy:element-dirty", { bubbles: true })
+ )
+ window.onbeforeunload = () => Alchemy.t("page_dirty_notice")
+ }
+
+ /**
+ * Sets the title quote
+ * @param {string} title
+ */
+ setTitle(title) {
+ const quote = this.querySelector(".element-header .preview_text_quote")
+ quote.textContent = title
+ }
+
+ /**
+ * Expands or collapses element editor
+ * If the element is dirty (has unsaved changes) it displays a confirm first.
+ */
+ async toggle() {
+ if (this.collapsed) {
+ await this.expand()
+ } else {
+ await this.collapse()
+ }
+ }
+
+ /**
+ * Collapses the element editor and persists the state on the server
+ * @returns {Promise}
+ */
+ collapse() {
+ if (this.collapsed || this.compact || this.fixed) {
+ return Promise.resolve("Element is already collapsed.")
+ }
+
+ const spinner = new Alchemy.Spinner("small")
+ spinner.spin(this.toggleButton)
+ this.toggleIcon?.classList?.add("hidden")
+ return post(Alchemy.routes.collapse_admin_element_path(this.elementId))
+ .then((response) => {
+ const data = response.data
+
+ this.collapsed = true
+ this.toggleButton?.setAttribute("title", data.title)
+
+ // Collapse all nested elements if necessarry
+ if (data.nestedElementIds.length) {
+ const selector = data.nestedElementIds
+ .map((id) => `#element_${id}`)
+ .join(", ")
+ this.querySelectorAll(selector).forEach((nestedElement) => {
+ nestedElement.collapsed = true
+ nestedElement.toggleButton?.setAttribute("title", data.title)
+ })
+ }
+ })
+ .catch((error) => {
+ Alchemy.growl(error.message, "error")
+ console.error(error)
+ })
+ .finally(() => {
+ this.toggleIcon?.classList?.remove("hidden")
+ spinner.stop()
+ })
+ }
+
+ /**
+ * Collapses the element editor and persists the state on the server
+ * @* @returns {Promise}
+ */
+ expand() {
+ if (this.expanded && !this.compact) {
+ return Promise.resolve("Element is already expanded.")
+ }
+
+ if (this.compact && this.parentElementEditor) {
+ return this.parentElementEditor.expand()
+ } else {
+ const spinner = new Alchemy.Spinner("small")
+ spinner.spin(this.toggleButton)
+ this.toggleIcon?.classList.add("hidden")
+
+ return new Promise((resolve, reject) => {
+ post(Alchemy.routes.expand_admin_element_path(this.elementId))
+ .then((response) => {
+ const data = response.data
+
+ // First expand all parent elements if necessary
+ if (data.parentElementIds.length) {
+ const selector = data.parentElementIds
+ .map((id) => `#element_${id}`)
+ .join(", ")
+ document.querySelectorAll(selector).forEach((parentElement) => {
+ parentElement.collapsed = false
+ parentElement.toggleButton?.setAttribute("title", data.title)
+ })
+ }
+ // Finally expand ourselve
+ this.collapsed = false
+ this.toggleButton?.setAttribute("title", data.title)
+ // Resolve the promise that scrolls to the element very last
+ resolve()
+ })
+ .catch((error) => {
+ Alchemy.growl(error.message, "error")
+ console.error(error)
+ reject(error)
+ })
+ .finally(() => {
+ this.toggleIcon?.classList?.remove("hidden")
+ spinner.stop()
+ })
+ })
+ }
+ }
+
+ /**
+ * Updates the quote in the element header and dispatches event
+ * to parent elements
+ * @param {string} title
+ */
+ updateTitle(title) {
+ this.setTitle(title)
+ this.dispatchEvent(
+ new CustomEvent("alchemy:element-update-title", {
+ bubbles: true,
+ detail: { title }
+ })
+ )
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ get compact() {
+ return this.getAttribute("compact") !== null
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ get fixed() {
+ return this.getAttribute("fixed") !== null
+ }
+
+ /**
+ * @param {boolean} value
+ */
+ set collapsed(value) {
+ this.classList.toggle("folded", value)
+ this.classList.toggle("expanded", !value)
+ this.toggleIcon?.classList?.toggle("fa-minus-square", !value)
+ this.toggleIcon?.classList?.toggle("fa-plus-square", value)
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ get collapsed() {
+ return this.classList.contains("folded")
+ }
+
+ /**
+ * @returns {boolean}
+ */
+ get expanded() {
+ return !this.collapsed
+ }
+
+ /**
+ * Toggles the dirty class
+ *
+ * @param {boolean} value
+ */
+ set dirty(value) {
+ this.classList.toggle("dirty", value)
+ }
+
+ /**
+ * Returns the dirty state of this element
+ *
+ * @returns {boolean}
+ */
+ get dirty() {
+ return this.classList.contains("dirty")
+ }
+
+ /**
+ * Returns the element header
+ *
+ * @returns {HTMLElement|undefined}
+ */
+ get header() {
+ return this.querySelector(`.element-header`)
+ }
+
+ /**
+ * Returns the immediate body container of this element if present
+ *
+ * Makes sure it does not return a nested elements body
+ * by scoping the selector to this elements id.
+ *
+ * @returns {HTMLElement|undefined}
+ */
+ get body() {
+ return this.querySelector(this.bodySelector)
+ }
+
+ get bodySelector() {
+ return `#${this.id} > .element-body`
+ }
+
+ /**
+ * Returns the immediate footer container of this element if present
+ *
+ * Makes sure it does not return a nested elements footer
+ * by scoping the selector to this elements id.
+ *
+ * @returns {HTMLElement|undefined}
+ */
+ get footer() {
+ return this.querySelector(`#${this.id} > .element-footer`)
+ }
+
+ /**
+ * The collapse/expand toggle button
+ *
+ * @returns {HTMLButtonElement|undefined}
+ */
+ get toggleButton() {
+ return this.querySelector(".element-toggle")
+ }
+
+ /**
+ * The collapse/expand toggle buttons icon
+ *
+ * @returns {HTMLElement|undefined}
+ */
+ get toggleIcon() {
+ return this.toggleButton?.querySelector(".icon")
+ }
+
+ /**
+ * The error messages container
+ *
+ * @returns {HTMLElement}
+ */
+ get errorsDisplay() {
+ return this.body.querySelector(".error-messages")
+ }
+
+ /**
+ * The validation messages list container
+ *
+ * @returns {HTMLElement}
+ */
+ get elementErrors() {
+ return this.body.querySelector(".element_errors")
+ }
+
+ /**
+ * The element database id
+ *
+ * @returns {string}
+ */
+ get elementId() {
+ return this.dataset.elementId
+ }
+
+ /**
+ * The element defintion name
+ *
+ * @returns {string}
+ */
+ get elementName() {
+ return this.dataset.elementName
+ }
+
+ /**
+ * Does this element have ingredient editor fields?
+ *
+ * @returns {boolean}
+ */
+ get hasEditors() {
+ return !!this.body?.querySelector(".element-ingredient-editors")
+ }
+
+ /**
+ * Does this element have nested elements?
+ *
+ * @returns {boolean}
+ */
+ get hasChildren() {
+ return !!this.querySelector(".nested-elements")
+ }
+
+ /**
+ * The first child element editor if present
+ *
+ * @returns {HTMLButtonElement|undefined}
+ */
+ get firstChild() {
+ return this.querySelector("alchemy-element-editor")
+ }
+
+ /**
+ * The parent element editor if present
+ *
+ * @returns {ElementEditor|undefined}
+ */
+ get parentElementEditor() {
+ return this.parentElement?.closest("alchemy-element-editor")
+ }
+}
+
+customElements.define("alchemy-element-editor", ElementEditor)
diff --git a/app/javascript/alchemy_admin/components/ingredient_group.js b/app/javascript/alchemy_admin/components/ingredient_group.js
new file mode 100644
index 0000000000..c9dbac2ae1
--- /dev/null
+++ b/app/javascript/alchemy_admin/components/ingredient_group.js
@@ -0,0 +1,54 @@
+export class IngredientGroup extends HTMLDetailsElement {
+ #localStorageKey = "Alchemy.expanded_ingredient_groups"
+
+ constructor() {
+ super()
+
+ this.addEventListener("toggle", this)
+
+ if (this.isInLocalStorage) {
+ this.open = true
+ }
+ }
+
+ /**
+ * Toggle visibility of the ingredient fields in this group
+ */
+ handleEvent() {
+ let expanded_ingredient_groups = this.localStorageItem
+
+ if (this.open) {
+ if (!this.isInLocalStorage) expanded_ingredient_groups.push(this.id)
+ } else {
+ expanded_ingredient_groups = expanded_ingredient_groups.filter(
+ (value) => value !== this.id
+ )
+ }
+
+ localStorage.setItem(
+ this.#localStorageKey,
+ JSON.stringify(expanded_ingredient_groups)
+ )
+ }
+
+ get isInLocalStorage() {
+ return this.localStorageItem.includes(this.id)
+ }
+
+ get localStorageItem() {
+ const item = localStorage.getItem(this.#localStorageKey)
+
+ if (!item) return []
+
+ try {
+ return JSON.parse(item)
+ } catch (error) {
+ console.error(error)
+ return []
+ }
+ }
+}
+
+customElements.define("alchemy-ingredient-group", IngredientGroup, {
+ extends: "details"
+})
diff --git a/app/javascript/alchemy_admin/components/tinymce.js b/app/javascript/alchemy_admin/components/tinymce.js
index 49f93e4cee..1548e4d7df 100644
--- a/app/javascript/alchemy_admin/components/tinymce.js
+++ b/app/javascript/alchemy_admin/components/tinymce.js
@@ -1,6 +1,11 @@
import { AlchemyHTMLElement } from "./alchemy_html_element"
import { currentLocale } from "alchemy_admin/i18n"
+const TOOLBAR_ROW_HEIGHT = 30
+const TOOLBAR_BORDER_WIDTH = 1
+const STATUSBAR_HEIGHT = 29.5
+const EDITOR_BORDER_WIDTH = 2
+
class Tinymce extends AlchemyHTMLElement {
/**
* the observer will initialize Tinymce if the textarea becomes visible
@@ -53,6 +58,7 @@ class Tinymce extends AlchemyHTMLElement {
* hide the textarea until TinyMCE is ready to show the editor
*/
afterRender() {
+ this.style.minHeight = `${this.minHeight}px`
this.editor.style.display = "none"
}
@@ -72,11 +78,8 @@ class Tinymce extends AlchemyHTMLElement {
this.getElementsByTagName("alchemy-spinner")[0].remove()
// event listener to mark the editor as dirty
- editor.on("dirty", () => Alchemy.setElementDirty(this.elementEditor))
- editor.on("click", (event) => {
- event.target = this.elementEditor
- Alchemy.ElementEditors.onClickElement(event)
- })
+ editor.on("dirty", () => this.elementEditor.setDirty())
+ editor.on("click", () => this.elementEditor.onClickElement(false))
})
})
}
@@ -116,7 +119,27 @@ class Tinymce extends AlchemyHTMLElement {
}
get elementEditor() {
- return document.getElementById(this.editorId).closest(".element-editor")
+ return document
+ .getElementById(this.editorId)
+ .closest("alchemy-element-editor")
+ }
+
+ get minHeight() {
+ let minHeight = this.configuration.min_height || 0
+
+ if (Array.isArray(this.configuration.toolbar)) {
+ minHeight += this.configuration.toolbar.length * TOOLBAR_ROW_HEIGHT
+ minHeight += TOOLBAR_BORDER_WIDTH
+ } else if (this.configuration.toolbar) {
+ minHeight += TOOLBAR_ROW_HEIGHT
+ minHeight += TOOLBAR_BORDER_WIDTH
+ }
+ if (this.configuration.statusbar) {
+ minHeight += STATUSBAR_HEIGHT
+ }
+ minHeight += EDITOR_BORDER_WIDTH
+
+ return minHeight
}
}
diff --git a/app/javascript/alchemy_admin/dirty.js b/app/javascript/alchemy_admin/dirty.js
index 396c843214..18db98a09b 100644
--- a/app/javascript/alchemy_admin/dirty.js
+++ b/app/javascript/alchemy_admin/dirty.js
@@ -1,31 +1,5 @@
-function ElementDirtyObserver(selector) {
- $(selector)
- .find('input[type="text"], select')
- .change(function (event) {
- const $content = $(event.target)
- $content.addClass("dirty")
- setElementDirty($content.closest(".element-editor"))
- })
-}
-
-function setElementDirty(element) {
- $(element).addClass("dirty")
- window.onbeforeunload = () => Alchemy.t("page_dirty_notice")
-}
-
-function setElementClean(element) {
- const $element = $(element)
- $element.removeClass("dirty")
- $element.find("> .element-body .dirty").removeClass("dirty")
- window.onbeforeunload = () => {}
-}
-
function isPageDirty() {
- return $("#element_area").find(".element-editor.dirty").length > 0
-}
-
-function isElementDirty(element) {
- return $(element).hasClass("dirty")
+ return $("#element_area").find("alchemy-element-editor.dirty").length > 0
}
function checkPageDirtyness(element) {
@@ -70,10 +44,6 @@ function PageLeaveObserver() {
}
export default {
- ElementDirtyObserver,
- setElementDirty,
- setElementClean,
- isElementDirty,
checkPageDirtyness,
PageLeaveObserver
}
diff --git a/app/javascript/alchemy_admin/file_editors.js b/app/javascript/alchemy_admin/file_editors.js
index 2e26396d8d..da5ab8ab2a 100644
--- a/app/javascript/alchemy_admin/file_editors.js
+++ b/app/javascript/alchemy_admin/file_editors.js
@@ -16,7 +16,7 @@ class FileEditor {
this.fileIcon.innerHTML = ""
this.fileName.innerHTML = ""
this.deleteLink.classList.add("hidden")
- Alchemy.setElementDirty(this.container.closest(".element-editor"))
+ this.container.closest("alchemy-element-editor").setDirty()
return false
}
}
diff --git a/app/javascript/alchemy_admin/gui.js b/app/javascript/alchemy_admin/gui.js
index fcc0b2991a..47d1f72dcc 100644
--- a/app/javascript/alchemy_admin/gui.js
+++ b/app/javascript/alchemy_admin/gui.js
@@ -9,19 +9,6 @@ function init(scope) {
TagsAutocomplete(scope)
}
-function initElement($el) {
- Alchemy.ElementDirtyObserver($el)
- init($el && $el.selector)
- Alchemy.ImageLoader($el[0])
- Alchemy.fileEditors(
- $el.find(
- ".ingredient-editor.file, .ingredient-editor.audio, .ingredient-editor.video"
- ).selector
- )
- Alchemy.pictureEditors($el.find(".ingredient-editor.picture").selector)
-}
-
export default {
- init,
- initElement
+ init
}
diff --git a/app/javascript/alchemy_admin/image_cropper.js b/app/javascript/alchemy_admin/image_cropper.js
index 6e105f3cc6..b66564c650 100644
--- a/app/javascript/alchemy_admin/image_cropper.js
+++ b/app/javascript/alchemy_admin/image_cropper.js
@@ -91,7 +91,10 @@ export default class ImageCropper {
bind() {
this.dialog.dialog_body.find('button[type="submit"]').click(() => {
- Alchemy.setElementDirty(`[data-element-id='${this.elementId}']`)
+ const elementEditor = document.querySelector(
+ `[data-element-id='${this.elementId}']`
+ )
+ elementEditor.setDirty()
this.dialog.close()
return false
})
diff --git a/app/javascript/alchemy_admin/picture_editors.js b/app/javascript/alchemy_admin/picture_editors.js
index 1ca4f55682..029f89a77f 100644
--- a/app/javascript/alchemy_admin/picture_editors.js
+++ b/app/javascript/alchemy_admin/picture_editors.js
@@ -93,7 +93,7 @@ class PictureEditor {
this.pictureIdField.value = ""
this.image = null
this.cropLink.classList.add("disabled")
- Alchemy.setElementDirty(this.container.closest(".element-editor"))
+ this.container.closest(".element-editor").setDirty()
}
updateCropLink() {
diff --git a/app/javascript/alchemy_admin/utils/ajax.js b/app/javascript/alchemy_admin/utils/ajax.js
index 64c2e5947b..4b08b69c60 100644
--- a/app/javascript/alchemy_admin/utils/ajax.js
+++ b/app/javascript/alchemy_admin/utils/ajax.js
@@ -37,11 +37,11 @@ export function patch(url, data) {
return ajax("PATCH", url, data)
}
-export function post(url, data) {
- return ajax("POST", url, data)
+export function post(url, data, accept = "application/json") {
+ return ajax("POST", url, data, accept)
}
-export default function ajax(method, path, data) {
+export default function ajax(method, path, data, accept = "application/json") {
const xhr = new XMLHttpRequest()
const promise = buildPromise(xhr)
const url = new URL(window.location.origin + path)
@@ -52,7 +52,7 @@ export default function ajax(method, path, data) {
xhr.open(method, url.toString())
xhr.setRequestHeader("Content-type", "application/json; charset=utf-8")
- xhr.setRequestHeader("Accept", "application/json")
+ xhr.setRequestHeader("Accept", accept)
xhr.setRequestHeader("X-CSRF-Token", getToken())
if (data && method.toLowerCase() !== "get") {
diff --git a/app/javascript/alchemy_admin/utils/events.js b/app/javascript/alchemy_admin/utils/events.js
index 8d085ae993..933d8d5b85 100644
--- a/app/javascript/alchemy_admin/utils/events.js
+++ b/app/javascript/alchemy_admin/utils/events.js
@@ -1,7 +1,8 @@
export function on(eventName, baseSelector, targetSelector, callback) {
document.querySelectorAll(baseSelector).forEach((baseNode) => {
+ const targets = Array.from(baseNode.querySelectorAll(targetSelector))
+
baseNode.addEventListener(eventName, (evt) => {
- const targets = Array.from(baseNode.querySelectorAll(targetSelector))
let currentNode = evt.target
while (currentNode !== baseNode) {
diff --git a/app/models/alchemy/element.rb b/app/models/alchemy/element.rb
index a0a349df62..f94edfe010 100644
--- a/app/models/alchemy/element.rb
+++ b/app/models/alchemy/element.rb
@@ -183,6 +183,17 @@ def all_from_clipboard_for_parent_element(clipboard, parent_element)
end
end
+ # Heavily unoptimized naive way to get all parent ids
+ def parent_element_ids
+ ids ||= []
+ parent = parent_element
+ while parent
+ ids.push parent.id
+ parent = parent.parent_element
+ end
+ ids
+ end
+
# Returns next public element from same page.
#
# Pass an element name to get next of this kind.
diff --git a/app/views/alchemy/admin/attachments/assign.js.erb b/app/views/alchemy/admin/attachments/assign.js.erb
index 03f3bda4ba..c6732f5b2e 100644
--- a/app/views/alchemy/admin/attachments/assign.js.erb
+++ b/app/views/alchemy/admin/attachments/assign.js.erb
@@ -6,6 +6,6 @@
$form_field.parent().find("> .file_icon").html("<%= j render_icon(@attachment.icon_css_class) %>")
$form_field.parent().find("> .remove_file_link").removeClass("hidden")
Alchemy.closeCurrentDialog(function() {
- Alchemy.setElementDirty($form_field.closest(".element-editor"))
+ $form_field[0].closest("alchemy-element-editor").setDirty()
})
})()
diff --git a/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb b/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb
index 7e53ab3134..e74c3f713f 100644
--- a/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb
+++ b/app/views/alchemy/admin/elements/_add_nested_element_form.html.erb
@@ -1,27 +1,25 @@
<%= content_tag :div, class: 'add-nested-element', data: { element_id: element.id } do %>
- <% if element.expanded? || element.fixed? %>
- <% if element.nestable_elements.length == 1 &&
- (nestable_element = element.nestable_elements.first) &&
- Alchemy::Element.all_from_clipboard_for_parent_element(get_clipboard("elements"), element).none?
- %>
- <%= form_for [:admin, Alchemy::Element.new(name: nestable_element)],
- remote: true, html: { class: 'add-nested-element-form', id: nil } do |f| %>
- <%= f.hidden_field :name %>
- <%= f.hidden_field :page_version_id, value: element.page_version_id %>
- <%= f.hidden_field :parent_element_id, value: element.id %>
-
- <% end %>
- <% else %>
- <%= link_to_dialog (nestable_element ? Alchemy.t(:add_nested_element, name: Alchemy.t(nestable_element, scope: 'element_names')) : Alchemy.t("New Element")),
- alchemy.new_admin_element_path(
- parent_element_id: element.id,
- page_version_id: element.page_version_id
- ), {
- size: "320x125",
- title: Alchemy.t("New Element")
- }, class: "button add-nestable-element-button" %>
+ <% if element.nestable_elements.length == 1 &&
+ (nestable_element = element.nestable_elements.first) &&
+ Alchemy::Element.all_from_clipboard_for_parent_element(get_clipboard("elements"), element).none?
+ %>
+ <%= form_for [:admin, Alchemy::Element.new(name: nestable_element)],
+ remote: true, html: { class: 'add-nested-element-form', id: nil } do |f| %>
+ <%= f.hidden_field :name %>
+ <%= f.hidden_field :page_version_id, value: element.page_version_id %>
+ <%= f.hidden_field :parent_element_id, value: element.id %>
+
<% end %>
+ <% else %>
+ <%= link_to_dialog (nestable_element ? Alchemy.t(:add_nested_element, name: Alchemy.t(nestable_element, scope: 'element_names')) : Alchemy.t("New Element")),
+ alchemy.new_admin_element_path(
+ parent_element_id: element.id,
+ page_version_id: element.page_version_id
+ ), {
+ size: "320x125",
+ title: Alchemy.t("New Element")
+ }, class: "button add-nestable-element-button" %>
<% end %>
<% end %>
diff --git a/app/views/alchemy/admin/elements/_element.html.erb b/app/views/alchemy/admin/elements/_element.html.erb
index 6c2fede669..4dd15e93ea 100644
--- a/app/views/alchemy/admin/elements/_element.html.erb
+++ b/app/views/alchemy/admin/elements/_element.html.erb
@@ -1,59 +1,62 @@
-<%= content_tag :div,
- id: "element_#{element.id}",
- data: {'element-id' => element.id, 'element-name' => element.name},
- class: element.css_classes do %>
-
+
+ <%= element.fixed? ? "fixed" : nil %>
+>
<% unless element.fixed? %>
<%= render 'alchemy/admin/elements/header', element: element %>
<% end %>
- <% if element.expanded? || element.fixed? %>
- <%= render 'alchemy/admin/elements/toolbar', element: element %>
+ <%= render 'alchemy/admin/elements/toolbar', element: element %>
- <% element.definition[:message].tap do |message| %>
- <%= render_message(:info, sanitize(message)) if message %>
- <% end %>
+ <% element.definition[:message].tap do |message| %>
+ <%= render_message(:info, sanitize(message)) if message %>
+ <% end %>
- <% element.definition[:warning].tap do |warning| %>
- <%= render_message(:warning, sanitize(warning)) if warning %>
- <% end %>
+ <% element.definition[:warning].tap do |warning| %>
+ <%= render_message(:warning, sanitize(warning)) if warning %>
+ <% end %>
- <% if element.editable? %>
- <%= form_for [alchemy, :admin, element], remote: true,
- html: {id: "element_#{element.id}_form".html_safe, class: 'element-body'} do |f| %>
+ <% if element.editable? %>
+ <%= form_for [alchemy, :admin, element], remote: true,
+ html: {id: "element_#{element.id}_form".html_safe, class: 'element-body'} do |f| %>
-
+
+
<%= Alchemy.t("Validation failed") %>
+
<%= Alchemy.t(:ingredient_validations_headline) %>
+
+
-
- <% if element.has_ingredients_defined? %>
-
- <%= render element.ingredients.select { |i| !i.definition[:group] }, element_form: f %>
+
+ <% if element.has_ingredients_defined? %>
+
+ <%= render element.ingredients.select { |i| !i.definition[:group] }, element_form: f %>
-
- <% element.ingredients.select { |i| i.definition[:group] }.group_by { |i| i.definition[:group] }.each do |group, ingredients| %>
-
- <%= link_to '#', id: "element_#{element.id}_ingredient_group_#{group.parameterize.underscore}_header", class: 'ingredient-group-header', data: { toggle_ingredient_group: true } do %>
- <%= element.translated_group group %>
-
- <% end %>
- <%= content_tag :div, id: "element_#{element.id}_ingredient_group_#{group.parameterize.underscore}", class: 'ingredient-group-ingredients' do %>
- <%= render ingredients, element_form: f %>
- <% end %>
-
+
+ <% element.ingredients.select { |i| i.definition[:group] }.group_by { |i| i.definition[:group] }.each do |group, ingredients| %>
+ <%= content_tag :details, class: "ingredient-group", id: "element_#{element.id}_ingredient_group_#{group.parameterize.underscore}", is: "alchemy-ingredient-group" do %>
+
+ <%= element.translated_group group %>
+
+
+ <%= render ingredients, element_form: f %>
<% end %>
-
- <% end %>
-
- <% if element.taggable? %>
-
- <%= f.label :tag_list %>
- <%= render 'alchemy/admin/partials/autocomplete_tag_list', f: f %>
-
- <% end %>
+ <% end %>
+
<% end %>
- <%= render 'alchemy/admin/elements/footer', element: element %>
+ <% if element.taggable? %>
+
+ <%= f.label :tag_list %>
+ <%= render 'alchemy/admin/partials/autocomplete_tag_list', f: f %>
+
+ <% end %>
<% end %>
+
+ <%= render 'alchemy/admin/elements/footer', element: element %>
<% end %>
<%# We need to render nested elements even if the element is folded,
@@ -73,4 +76,4 @@
<%= render "alchemy/admin/elements/add_nested_element_form", element: element %>
<% end %>
-<% end %>
+
diff --git a/app/views/alchemy/admin/elements/_header.html.erb b/app/views/alchemy/admin/elements/_header.html.erb
index 9007246922..3eb7519a04 100644
--- a/app/views/alchemy/admin/elements/_header.html.erb
+++ b/app/views/alchemy/admin/elements/_header.html.erb
@@ -19,12 +19,10 @@
<%= render_hint_for(element) %>
- <%= link_to '#', {
- 'data-element-toggle' => element.id,
+ <%= button_tag({
title: Alchemy.t(element.folded? ? :show_element_content : :hide_element_content),
- id: "element_#{element.id}_folder",
- class: "ajax-folder"
- } do %>
+ class: "element-toggle"
+ }) do %>
<%= render_icon element.folded? ? 'plus-square' : 'minus-square' %>
<% end %>
diff --git a/app/views/alchemy/admin/elements/create.js.erb b/app/views/alchemy/admin/elements/create.js.erb
index 1f89e986c3..b06ab09aea 100644
--- a/app/views/alchemy/admin/elements/create.js.erb
+++ b/app/views/alchemy/admin/elements/create.js.erb
@@ -1,5 +1,4 @@
(function() {
- var $el;
var $element_area;
var element_html = '<%= j render(Alchemy::ElementEditor.new(@element)) %>';
@@ -33,13 +32,10 @@
Alchemy.growl('<%= Alchemy.t(:successfully_added_element) %>');
Alchemy.closeCurrentDialog();
- Alchemy.PreviewWindow.refresh(function() {
- Alchemy.ElementEditors.focusElementPreview(<%= @element.id %>);
- });
- $el = $('#element_<%= @element.id %>');
- $el.trigger('FocusElementEditor.Alchemy');
- Alchemy.GUI.initElement($el);
+ el = document.querySelector('#element_<%= @element.id %>');
+ el.focusElement();
+ el.focusElementPreview();
<%- if @clipboard.blank? -%>
$('#clipboard_button .icon.clipboard').removeClass('full');
diff --git a/app/views/alchemy/admin/elements/fold.js.erb b/app/views/alchemy/admin/elements/fold.js.erb
deleted file mode 100644
index 2694cc723c..0000000000
--- a/app/views/alchemy/admin/elements/fold.js.erb
+++ /dev/null
@@ -1,26 +0,0 @@
-(function() {
- var $el = $('.element-editor[data-element-id="<%= @element.id %>"]');
-
-<% if @error -%>
-
- $("#element_<%= @element.id %> .spinner")
- .replaceWith("!");
-
-<% else -%>
-
- $el.replaceWith('<%= j render(Alchemy::ElementEditor.new(@element)) %>');
- $el = $('#element_<%= @element.id %>');
- $('#element_area .sortable-elements').sortable('refresh');
-
- <% unless @element.folded? -%>
- $el.trigger('FocusElementEditor.Alchemy');
- Alchemy.GUI.initElement($el);
- Alchemy.SortableElements(
- <%= @page.id %>,
- '<%= form_authenticity_token %>',
- $('> .nestable-elements .nested-elements', $el)
- );
- <% end -%>
-
-<% end -%>
-})();
diff --git a/app/views/alchemy/admin/elements/index.html.erb b/app/views/alchemy/admin/elements/index.html.erb
index 79bb3c0f93..c662ede719 100644
--- a/app/views/alchemy/admin/elements/index.html.erb
+++ b/app/views/alchemy/admin/elements/index.html.erb
@@ -8,7 +8,7 @@
<%= element.display_name %>
<% end %>
-
+
<%= render @elements.map { |element| Alchemy::ElementEditor.new(element) } %>
<% @fixed_elements.each do |element| %>
diff --git a/app/views/alchemy/admin/elements/order.js.erb b/app/views/alchemy/admin/elements/order.js.erb
index 3ca7337e2f..0052091e4d 100644
--- a/app/views/alchemy/admin/elements/order.js.erb
+++ b/app/views/alchemy/admin/elements/order.js.erb
@@ -1,9 +1,8 @@
(function() {
<% if @parent_element && @parent_element.ingredients.empty? %>
- var $parent = $('#element_<%= @parent_element.id %>');
- Alchemy.ElementEditors.setTitle(
- $parent,
- '<%= sanitize(@parent_element.preview_text.presence || ' ') %>'
+ var parent = document.querySelector('#element_<%= @parent_element.id %>');
+ parent.setTitle(
+ '<%= sanitize(@parent_element.preview_text) %>'
);
<% end %>
Alchemy.growl('<%= Alchemy.t(:successfully_saved_element_position) -%>');
diff --git a/app/views/alchemy/admin/elements/update.js.erb b/app/views/alchemy/admin/elements/update.js.erb
deleted file mode 100644
index 7cc9264ca6..0000000000
--- a/app/views/alchemy/admin/elements/update.js.erb
+++ /dev/null
@@ -1,26 +0,0 @@
-(function() {
- var $el = $('#element_<%= @element.id %>');
- var $errors = $('#element_<%= @element.id %>_errors');
- $('> .element-body .ingredient-editor', $el).removeClass('validation_failed');
-
-<%- if @element_validated -%>
-
- $errors.hide();
- $el.trigger('SaveElement.Alchemy', {previewText: '<%= j sanitize(@element.preview_text) %>'});
- <% @element.ingredients.select { |i| i.settings[:anchor] }.each do |ingredient| %>
- Alchemy.IngredientAnchorLink.updateIcon(<%= ingredient.id %>, <%= ingredient.dom_id.present? %>);
- <% end %>
- Alchemy.growl('<%= Alchemy.t(:element_saved) %>');
- Alchemy.PreviewWindow.refresh(function() {
- Alchemy.ElementEditors.focusElementPreview(<%= @element.id %>);
- });
-
-<%- else -%>
-
- Alchemy.growl('<%= j @notice %>', 'warn');
- $errors.html('<%= j @error_message %>- <%== j @error_messages.join("
- ") %>
');
- $errors.show();
- $('<%== @element.ingredients_with_errors.map { |ingredient| "[data-ingredient-id=\"#{ingredient.id}\"]" }.join(", ") %>').addClass('validation_failed');
-
-<%- end -%>
-})();
diff --git a/app/views/alchemy/admin/pages/edit.html.erb b/app/views/alchemy/admin/pages/edit.html.erb
index 210e88dfb9..429b38bb0f 100644
--- a/app/views/alchemy/admin/pages/edit.html.erb
+++ b/app/views/alchemy/admin/pages/edit.html.erb
@@ -189,10 +189,7 @@
}
]
}, function() {
- Alchemy.ImageLoader('#element_area');
Alchemy.SortableElements(<%= @page.id %>, '<%= form_authenticity_token %>');
- Alchemy.ElementEditors.init();
- Alchemy.ElementDirtyObserver('#element_area');
if (window.location.hash) {
$(window.location.hash).trigger('FocusElementEditor.Alchemy');
}
diff --git a/app/views/alchemy/admin/partials/_routes.html.erb b/app/views/alchemy/admin/partials/_routes.html.erb
index f518104735..390e21aab7 100644
--- a/app/views/alchemy/admin/partials/_routes.html.erb
+++ b/app/views/alchemy/admin/partials/_routes.html.erb
@@ -9,8 +9,12 @@
return '<%= alchemy.url_admin_picture_path(id: 1) %>'.replace(/1/, id);
},
- fold_admin_element_path: function(id) {
- return '<%= alchemy.fold_admin_element_path(id: 1) %>'.replace(/1/, id);
+ collapse_admin_element_path: function(id) {
+ return '<%= alchemy.collapse_admin_element_path(id: 1) %>'.replace(/1/, id);
+ },
+
+ expand_admin_element_path: function(id) {
+ return '<%= alchemy.expand_admin_element_path(id: 1) %>'.replace(/1/, id);
},
node: {
diff --git a/app/views/alchemy/admin/pictures/assign.js.erb b/app/views/alchemy/admin/pictures/assign.js.erb
index 06426cd05e..c354425b5f 100644
--- a/app/views/alchemy/admin/pictures/assign.js.erb
+++ b/app/views/alchemy/admin/pictures/assign.js.erb
@@ -5,6 +5,6 @@
$form_field.attr("data-image-file-width", <%= @picture.image_file_width %>)
$form_field.attr("data-image-file-height", <%= @picture.image_file_height %>)
Alchemy.closeCurrentDialog(function() {
- Alchemy.setElementDirty($form_field.closest(".element-editor"))
+ $form_field[0].closest("alchemy-element-editor").setDirty()
})
})()
diff --git a/config/routes.rb b/config/routes.rb
index 21e50dd0c4..8a9f220589 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -45,7 +45,8 @@
end
member do
patch :publish
- post :fold
+ post :collapse
+ post :expand
end
end
diff --git a/lib/alchemy/tinymce.rb b/lib/alchemy/tinymce.rb
index a8dab50996..d68c8a1d6a 100644
--- a/lib/alchemy/tinymce.rb
+++ b/lib/alchemy/tinymce.rb
@@ -4,15 +4,14 @@ module Alchemy
module Tinymce
mattr_accessor :languages, :plugins
- DEFAULT_PLUGINS = %w[anchor autoresize charmap code directionality fullscreen hr link lists paste tabfocus table]
+ DEFAULT_PLUGINS = %w[anchor charmap code directionality fullscreen hr link lists paste tabfocus table]
@@plugins = DEFAULT_PLUGINS + %w[alchemy_link]
@@init = {
skin: "alchemy",
width: "auto",
resize: true,
- autoresize_min_height: "105",
- autoresize_max_height: "480",
+ min_height: 220,
menubar: false,
statusbar: true,
toolbar: [
diff --git a/spec/controllers/alchemy/admin/elements_controller_spec.rb b/spec/controllers/alchemy/admin/elements_controller_spec.rb
index ace25838c0..9950f1570c 100644
--- a/spec/controllers/alchemy/admin/elements_controller_spec.rb
+++ b/spec/controllers/alchemy/admin/elements_controller_spec.rb
@@ -244,52 +244,99 @@ module Alchemy
end
end
- describe "#fold" do
- subject { post :fold, params: {id: element.id}, xhr: true }
+ describe "#collapse" do
+ subject { post :collapse, params: {id: element.id} }
let(:page) { create(:alchemy_page) }
+ let(:element) { create(:alchemy_element, folded: false) }
before do
element.touchable_pages << page
end
- context "if element is folded" do
- let(:element) { create(:alchemy_element, folded: true) }
+ it "sets folded to true." do
+ expect(page).not_to receive(:touch)
+ expect { subject }.to change { element.reload.folded }.to(true)
+ end
+
+ context "if element has nested elements" do
+ let!(:nested_element) { create(:alchemy_element, parent_element: element) }
+ let!(:nested_nested_element) { create(:alchemy_element, parent_element: nested_element) }
+ let!(:nested_folded_element) { create(:alchemy_element, folded: true, parent_element: element) }
+ let!(:nested_nested_folded_element) { create(:alchemy_element, folded: true, parent_element: nested_folded_element) }
+ let!(:nested_compact_element) { create(:alchemy_element, :compact, parent_element: element) }
+ let!(:nested_nested_compact_element) { create(:alchemy_element, :compact, parent_element: nested_compact_element) }
+
+ it "collapses all nested not compact elements" do
+ subject
+ aggregate_failures do
+ expect(nested_element.reload).to be_folded
+ expect(nested_nested_element.reload).to be_folded
+ expect(nested_folded_element.reload).to be_folded
+ expect(nested_nested_folded_element.reload).to be_folded
+ expect(nested_compact_element.reload).to_not be_folded
+ expect(nested_nested_compact_element.reload).to_not be_folded
+ end
+ end
- it "sets folded to false." do
- expect(page).not_to receive(:touch)
- expect { subject }.to change { element.reload.folded }.to(false)
+ it "returns json" do
+ subject
+ expect(JSON.parse(response.body)).to eq({
+ "nestedElementIds" => [
+ nested_element.id,
+ nested_nested_element.id
+ ],
+ "title" => "Show content of this element."
+ })
end
end
+ end
- context "if element is not folded" do
- let(:element) { create(:alchemy_element, folded: false) }
+ describe "#expand" do
+ subject { post :expand, params: {id: element.id} }
- it "sets folded to true." do
- expect(page).not_to receive(:touch)
- expect { subject }.to change { element.reload.folded }.to(true)
- end
+ let(:page) { create(:alchemy_page) }
+ let(:element) { create(:alchemy_element, folded: true) }
- context "if element has nested elements" do
- let!(:nested_element) { create(:alchemy_element, parent_element: element) }
- let!(:nested_nested_element) { create(:alchemy_element, parent_element: nested_element) }
- let!(:nested_folded_element) { create(:alchemy_element, folded: true, parent_element: element) }
- let!(:nested_nested_folded_element) { create(:alchemy_element, folded: true, parent_element: nested_folded_element) }
- let!(:nested_compact_element) { create(:alchemy_element, :compact, parent_element: element) }
- let!(:nested_nested_compact_element) { create(:alchemy_element, :compact, parent_element: nested_compact_element) }
+ before do
+ element.touchable_pages << page
+ end
- it "collapses all nested not compact elements" do
- subject
- aggregate_failures do
- expect(nested_element.reload).to be_folded
- expect(nested_nested_element.reload).to be_folded
- expect(nested_folded_element.reload).to be_folded
- expect(nested_nested_folded_element.reload).to be_folded
- expect(nested_compact_element.reload).to_not be_folded
- expect(nested_nested_compact_element.reload).to_not be_folded
- end
+ it "sets folded to false." do
+ expect(page).not_to receive(:touch)
+ expect { subject }.to change { element.reload.folded }.to(false)
+ end
+
+ context "if element has parent elements" do
+ let!(:nested_element) { create(:alchemy_element, parent_element: element) }
+ let!(:nested_nested_element) { create(:alchemy_element, folded: true, parent_element: nested_element) }
+ let!(:nested_folded_element) { create(:alchemy_element, folded: true, parent_element: nested_nested_element) }
+ let!(:nested_nested_folded_element) { create(:alchemy_element, folded: true, parent_element: nested_folded_element) }
+
+ subject { post :expand, params: {id: nested_nested_folded_element.id} }
+
+ it "expands all parent elements" do
+ subject
+ aggregate_failures do
+ expect(nested_element.reload).to_not be_folded
+ expect(nested_nested_element.reload).to_not be_folded
+ expect(nested_folded_element.reload).to_not be_folded
+ expect(nested_nested_folded_element.reload).to_not be_folded
end
end
+
+ it "returns json" do
+ subject
+ expect(JSON.parse(response.body)).to eq({
+ "parentElementIds" => [
+ element.id,
+ nested_element.id,
+ nested_nested_element.id,
+ nested_folded_element.id
+ ],
+ "title" => "Hide this elements content."
+ })
+ end
end
end
end
diff --git a/spec/features/admin/edit_elements_feature_spec.rb b/spec/features/admin/edit_elements_feature_spec.rb
index ef81472ce2..1ab85d0df7 100644
--- a/spec/features/admin/edit_elements_feature_spec.rb
+++ b/spec/features/admin/edit_elements_feature_spec.rb
@@ -147,38 +147,9 @@
# Need to be on page editor rather than just admin_elements in order to have JS interaction
before { visit alchemy.edit_admin_page_path(element.page) }
- scenario "collapsed ingredient groups shown", :js do
- # No group ingredient initially visible
- expect(page).not_to have_selector(".ingredient-group-ingredients", visible: true)
-
- page.find("a#element_#{element.id}_ingredient_group_details_header", text: "Details").click
- # 'Details' group ingredient visible
- expect(page).to have_selector("#element_#{element.id}_ingredient_group_details", visible: true)
- within("#element_#{element.id}_ingredient_group_details") do
- expect(page).to have_selector("[data-ingredient-role='description']")
- expect(page).to have_selector("[data-ingredient-role='key_words']")
- end
- expect(page).to have_selector("#element_#{element.id}_ingredient_group_details", visible: true)
-
- # 'Size' group ingredient not visible
- expect(page).not_to have_selector("#element_#{element.id}_ingredient_group_size", visible: true)
-
- page.find("a#element_#{element.id}_ingredient_group_size_header", text: "Size").click
- # 'Size' group now visible
- expect(page).to have_selector("#element_#{element.id}_ingredient_group_size", visible: true)
- within("#element_#{element.id}_ingredient_group_size") do
- expect(page).to have_selector("[data-ingredient-role='width']")
- expect(page).to have_selector("[data-ingredient-role='height']")
- end
-
- page.find("a#element_#{element.id}_ingredient_group_size_header", text: "Size").click
- # 'Size' group hidden
- expect(page).not_to have_selector("#element_#{element.id}_ingredient_group_size", visible: true)
- end
-
scenario "expanded ingredient groups persist between visits", :js do
expect(page).not_to have_selector("#element_#{element.id}_ingredient_group_details", visible: true)
- page.find("a#element_#{element.id}_ingredient_group_details_header", text: "Details").click
+ page.find("details#element_#{element.id}_ingredient_group_details", text: "Details").click
expect(page).to have_selector("#element_#{element.id}_ingredient_group_details", visible: true)
visit alchemy.edit_admin_page_path(element.page)
expect(page).to have_selector("#element_#{element.id}_ingredient_group_details", visible: true)
diff --git a/spec/javascript/alchemy_admin/components/element_editor.spec.js b/spec/javascript/alchemy_admin/components/element_editor.spec.js
new file mode 100644
index 0000000000..8ef905e3a9
--- /dev/null
+++ b/spec/javascript/alchemy_admin/components/element_editor.spec.js
@@ -0,0 +1,1053 @@
+import TagsAutocomplete from "alchemy_admin/tags_autocomplete"
+import ImageLoader from "alchemy_admin/image_loader"
+import fileEditors from "alchemy_admin/file_editors"
+import pictureEditors from "alchemy_admin/picture_editors"
+import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
+import { ElementEditor } from "alchemy_admin/components/element_editor"
+import { renderComponent } from "./component.helper"
+
+jest.mock("alchemy_admin/tags_autocomplete", () => {
+ return {
+ __esModule: true,
+ default: jest.fn()
+ }
+})
+
+jest.mock("alchemy_admin/image_loader", () => {
+ return {
+ __esModule: true,
+ default: {
+ init: jest.fn()
+ }
+ }
+})
+
+jest.mock("alchemy_admin/file_editors", () => {
+ return {
+ __esModule: true,
+ default: jest.fn()
+ }
+})
+
+jest.mock("alchemy_admin/picture_editors", () => {
+ return {
+ __esModule: true,
+ default: jest.fn()
+ }
+})
+
+jest.mock("alchemy_admin/ingredient_anchor_link", () => {
+ return {
+ __esModule: true,
+ default: { updateIcon: jest.fn() }
+ }
+})
+
+jest.mock("alchemy_admin/utils/ajax", () => {
+ return {
+ __esModule: true,
+ post(url) {
+ return new Promise((resolve, reject) => {
+ switch (url) {
+ case "/admin/elements/123/collapse":
+ resolve({
+ data: {
+ nestedElementIds: ["666"]
+ }
+ })
+ break
+ case "/admin/elements/123/expand":
+ resolve({
+ data: {
+ parentElementIds: ["456"]
+ }
+ })
+ break
+ case "/admin/elements/456/expand":
+ resolve({
+ data: {
+ parentElementIds: []
+ }
+ })
+ break
+ case "/admin/elements/666/collapse":
+ case "/admin/elements/666/expand":
+ reject(new Error("Something went wrong!"))
+ break
+ default:
+ reject(new Error(`URL ${url} not found!`))
+ }
+ })
+ }
+ }
+})
+
+function getComponent(html) {
+ return renderComponent("alchemy-element-editor", html)
+}
+
+describe("alchemy-element-editor", () => {
+ let html = `
+
+
+
+
+
+ `
+ let editor
+
+ beforeEach(() => {
+ editor = getComponent(html)
+ Alchemy = {
+ Spinner: jest.fn(() => {
+ return {
+ spin: jest.fn(),
+ stop: jest.fn()
+ }
+ }),
+ growl: jest.fn(),
+ routes: {
+ collapse_admin_element_path(id) {
+ return `/admin/elements/${id}/collapse`
+ },
+ expand_admin_element_path(id) {
+ return `/admin/elements/${id}/expand`
+ }
+ },
+ PreviewWindow: {
+ postMessage: jest.fn(),
+ refresh: jest.fn()
+ }
+ }
+ Alchemy.PreviewWindow.postMessage.mockClear()
+ Alchemy.PreviewWindow.refresh.mockClear()
+ Alchemy.growl.mockClear()
+ })
+
+ describe("connectedCallback", () => {
+ beforeEach(() => {
+ TagsAutocomplete.mockClear()
+ ImageLoader.init.mockClear()
+ fileEditors.mockClear()
+ pictureEditors.mockClear()
+ })
+
+ describe("if dragged", () => {
+ it("does not initializes", () => {
+ const html = `
+
+ `
+ getComponent(html)
+ expect(ImageLoader.init).not.toHaveBeenCalled()
+ })
+ })
+
+ it("initializes image loader", () => {
+ getComponent(html)
+ expect(ImageLoader.init).toHaveBeenCalled()
+ })
+
+ it("initializes file editors", () => {
+ getComponent(html)
+ expect(fileEditors).toHaveBeenCalled()
+ })
+
+ it("initializes picture editors", () => {
+ getComponent(html)
+ expect(pictureEditors).toHaveBeenCalled()
+ })
+
+ it("initializes tags autocomplete", () => {
+ getComponent(html)
+ expect(TagsAutocomplete).toHaveBeenCalled()
+ })
+ })
+
+ describe("on click", () => {
+ it("marks element editor as selected", () => {
+ return new Promise((resolve) => {
+ const click = new Event("click", { bubbles: true })
+ editor.dispatchEvent(click)
+ window.requestAnimationFrame(() => {
+ expect(editor.classList.contains("selected")).toBeTruthy()
+ resolve()
+ })
+ })
+ })
+
+ it("focuses element in preview", () => {
+ const click = new Event("click", { bubbles: true })
+ editor.dispatchEvent(click)
+ expect(Alchemy.PreviewWindow.postMessage).toHaveBeenCalledWith({
+ message: "Alchemy.focusElement",
+ element_id: "123"
+ })
+ })
+ })
+
+ describe("on doubleclick", () => {
+ it("toggles element", () => {
+ const dblclick = new Event("dblclick", { bubbles: true })
+ const originalToggle = ElementEditor.prototype.toggle
+ ElementEditor.prototype.toggle = jest.fn()
+ editor.header.dispatchEvent(dblclick)
+ expect(ElementEditor.prototype.toggle).toHaveBeenCalled()
+ ElementEditor.prototype.toggle = originalToggle
+ })
+ })
+
+ describe("on click on toggle button", () => {
+ it("toggles element", () => {
+ const click = new Event("click", { bubbles: true })
+ const originalToggle = ElementEditor.prototype.toggle
+ ElementEditor.prototype.toggle = jest.fn()
+ editor.toggleButton.dispatchEvent(click)
+ expect(ElementEditor.prototype.toggle).toHaveBeenCalled()
+ ElementEditor.prototype.toggle = originalToggle
+ })
+ })
+
+ describe("if editor has nested elements", () => {
+ beforeEach(() => {
+ editor = getComponent(`
+
+
+
+
+ `)
+ })
+
+ describe("on alchemy:element-update-title", () => {
+ it("updates title if triggered on first child", () => {
+ const childElement = editor.querySelector("#element_123")
+ const event = new CustomEvent("alchemy:element-update-title", {
+ detail: { title: "New Title" },
+ bubbles: true
+ })
+ childElement.dispatchEvent(event)
+ expect(
+ editor.querySelector(".element-header .preview_text_quote")
+ .textContent
+ ).toBe("New Title")
+ })
+ })
+
+ describe("on alchemy:element-dirty", () => {
+ it("updates title if triggered on child element", () => {
+ const childElement = editor.querySelector("#element_123")
+ const event = new CustomEvent("alchemy:element-dirty", {
+ bubbles: true
+ })
+ childElement.dispatchEvent(event)
+ expect(editor.dirty).toBeTruthy()
+ })
+ })
+
+ describe("on alchemy:element-clean", () => {
+ it("updates title if triggered on child element", () => {
+ editor.dirty = true
+ const childElement = editor.querySelector("#element_123")
+ const event = new CustomEvent("alchemy:element-clean", {
+ bubbles: true
+ })
+ childElement.dispatchEvent(event)
+ expect(editor.dirty).toBeFalsy()
+ })
+ })
+ })
+
+ describe("on ajax:success", () => {
+ describe("if event was triggered on this element", () => {
+ it("sets element to saved state", () => {
+ const event = new CustomEvent("ajax:success", {
+ bubbles: true,
+ detail: [{ ingredientAnchors: [] }]
+ })
+ editor.dirty = true
+ editor.body.dispatchEvent(event)
+ expect(editor.dirty).toBeFalsy()
+ })
+ })
+
+ describe("if event was triggered on child element", () => {
+ it("does not set parent element to saved", () => {
+ editor = getComponent(`
+
+
+
+
+ `)
+ const event = new CustomEvent("ajax:success", {
+ bubbles: true,
+ detail: [{ previewText: "Child Element", ingredientAnchors: [] }]
+ })
+ const childElement = editor.querySelector("#element_789")
+ childElement.dirty = true
+ childElement.body.dispatchEvent(event)
+ expect(
+ editor.header.querySelector(".preview_text_quote").textContent
+ ).toBe("Lorem Ipsum")
+ expect(childElement.dirty).toBeFalsy()
+ })
+ })
+ })
+
+ describe("on change of inputs or selects", () => {
+ it("sets element to dirty state", () => {
+ editor = getComponent(`
+
+
+
+
+ `)
+ const event = new Event("change", { bubbles: true })
+ editor.dirty = false
+ editor.querySelector("input").dispatchEvent(event)
+ expect(editor.dirty).toBeTruthy()
+ })
+ })
+
+ describe("focusElement", () => {
+ describe("if tabs are present", () => {
+ it("selects tab for element", async () => {
+ editor = getComponent(`
+
+
+ Main Content
+
+
+
+
+
+ `)
+ const originalSelectTab = ElementEditor.prototype.selectTabForElement
+ ElementEditor.prototype.selectTabForElement = jest.fn()
+ await editor.focusElement()
+ expect(ElementEditor.prototype.selectTabForElement).toHaveBeenCalled()
+ ElementEditor.prototype.selectTabForElement = originalSelectTab
+ })
+ })
+
+ describe("if element is collapsed", () => {
+ it("expands element", async () => {
+ editor = getComponent(`
+
+ `)
+ const originalExpand = ElementEditor.prototype.expand
+ ElementEditor.prototype.expand = jest.fn()
+ await editor.focusElement()
+ expect(ElementEditor.prototype.expand).toHaveBeenCalled()
+ ElementEditor.prototype.expand = originalExpand
+ })
+ })
+
+ it("marks element as selected", async () => {
+ const originalSelect = ElementEditor.prototype.selectElement
+ ElementEditor.prototype.selectElement = jest.fn()
+ await editor.focusElement()
+ expect(ElementEditor.prototype.selectElement).toHaveBeenCalledWith(true)
+ ElementEditor.prototype.selectElement = originalSelect
+ })
+ })
+
+ describe("onSaveElement", () => {
+ describe("if response is successful", () => {
+ beforeEach(() => {
+ editor = getComponent(`
+
+
+
+
+
+ `)
+ const data = {
+ notice: "Element saved",
+ ingredientAnchors: [{ ingredientId: 55, active: true }]
+ }
+ editor.dirty = true
+ editor.onSaveElement(data)
+ })
+
+ it("sets element clean", () => {
+ expect(editor.dirty).toBeFalsy
+ })
+
+ it("resets validation errors", () => {
+ expect(editor.errorsDisplay.innerHTML).toBe("")
+ })
+
+ it("removes ingredient invalid state", () => {
+ expect(
+ editor.querySelector(`[data-ingredient-id="666"]`).classList
+ ).not.toContain("validation_failed")
+ })
+
+ it("updates ingredient anchors icon", () => {
+ expect(IngredientAnchorLink.updateIcon).toHaveBeenCalledWith(55, true)
+ })
+
+ it("growls success", () => {
+ expect(Alchemy.growl).toHaveBeenCalledWith("Element saved")
+ })
+ })
+
+ describe("if response has validation errors", () => {
+ beforeEach(() => {
+ editor = getComponent(`
+
+
+
+
+ `)
+ const data = {
+ warning: "Something is not right",
+ errors: ["Please enter a value"],
+ ingredientsWithErrors: [666]
+ }
+ editor.onSaveElement(data)
+ })
+
+ it("displays errors", () => {
+ expect(editor.errorsDisplay.querySelector("li").textContent).toBe(
+ "Please enter a value"
+ )
+ })
+
+ it("marks ingredients as invalid", () => {
+ expect(
+ editor.querySelector(`[data-ingredient-id="666"]`).classList
+ ).toContain("validation_failed")
+ })
+
+ it("growls a warning", () => {
+ expect(Alchemy.growl).toHaveBeenCalledWith(
+ "Something is not right",
+ "warn"
+ )
+ })
+ })
+ })
+
+ describe("scrollToElement", () => {
+ it("scrolls to element", () => {
+ ElementEditor.prototype.scrollIntoView = jest.fn()
+ editor.scrollToElement()
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ expect(editor.scrollIntoView).toHaveBeenCalledWith({
+ behavior: "smooth"
+ })
+ resolve()
+ }, 50)
+ })
+ })
+ })
+
+ describe("selectElement", () => {
+ it("marks all other element editors as unselected", () => {
+ const editor = getComponent(`
+
+
+ `)
+ editor.selectElement()
+ const el = document.querySelector("#element_666")
+ expect(el.classList.contains("selected")).toBeFalsy()
+ })
+
+ it("marks element editor as selected", () => {
+ return new Promise((resolve) => {
+ editor.selectElement()
+ window.requestAnimationFrame(() => {
+ expect(editor.classList.contains("selected")).toBeTruthy()
+ resolve()
+ })
+ })
+ })
+
+ describe("with scroll enabled", () => {
+ it("scrolls to element", () => {
+ const scrollSpy = jest.spyOn(editor, "scrollToElement")
+ editor.selectElement(true)
+ expect(scrollSpy).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe("selectTabForElement", () => {
+ describe("if tabs are not present", () => {
+ it("rejects with error", async () => {
+ await editor.selectTabForElement().catch((error) => {
+ expect(error.message).toBe("No tabs present")
+ })
+ })
+ })
+
+ describe("if tabs are present", () => {
+ it("selects tab", async () => {
+ editor = getComponent(`
+
+
+ Main Content
+
+
+
+
+
+ `)
+ const tabgroup = document.querySelector("sl-tab-group")
+ tabgroup.show = jest.fn()
+ await editor.selectTabForElement().then(() => {
+ expect(tabgroup.show).toHaveBeenCalledWith("main-content-elements")
+ })
+ })
+ })
+ })
+
+ describe("setClean", () => {
+ beforeEach(() => {
+ editor = getComponent(`
+
+
+
+ `)
+ editor.setClean()
+ })
+
+ it("sets dirty to false", () => {
+ expect(editor.dirty).toBeFalsy()
+ })
+
+ it("removes beforeunload", () => {
+ expect(window.onbeforeunload).toBeNull()
+ })
+
+ it("dispatches element-clean event", () => {
+ return new Promise((resolve) => {
+ editor.addEventListener("alchemy:element-clean", () => {
+ resolve()
+ })
+ editor.setClean()
+ })
+ })
+
+ it("sets all ingredient editors clean", () => {
+ editor.body.querySelectorAll(".ingredient-editor").forEach((el) => {
+ expect(el.classList.contains("dirty")).toBeFalsy()
+ })
+ })
+ })
+
+ describe("setDirty", () => {
+ it("sets dirty to true", () => {
+ editor.setDirty()
+ expect(editor.dirty).toBeTruthy
+ })
+
+ it("dispatches element-dirty event", () => {
+ return new Promise((resolve) => {
+ editor.addEventListener("alchemy:element-dirty", () => {
+ resolve()
+ })
+ editor.setDirty()
+ })
+ })
+
+ it("sets beforeunload", () => {
+ editor.setDirty()
+ expect(window.onbeforeunload).toBeInstanceOf(Function)
+ })
+ })
+
+ describe("setTitle", () => {
+ it("sets title", () => {
+ editor.setTitle("Foo bar")
+ expect(
+ editor.querySelector(".element-header .preview_text_quote").textContent
+ ).toBe("Foo bar")
+ })
+ })
+
+ describe("toggle", () => {
+ describe("if collapsed", () => {
+ it("expands element", async () => {
+ const editor = getComponent(`
+
+ `)
+ originalExpand = ElementEditor.prototype.expand
+ ElementEditor.prototype.expand = jest.fn()
+ await editor.toggle()
+ expect(editor.expand).toHaveBeenCalled()
+ ElementEditor.prototype.expand = originalExpand
+ })
+ })
+
+ describe("if expanded", () => {
+ it("collapses element", async () => {
+ const editor = getComponent(html)
+ originalCollapse = ElementEditor.prototype.collapse
+ ElementEditor.prototype.collapse = jest.fn()
+ await editor.toggle()
+ expect(editor.collapse).toHaveBeenCalled()
+ ElementEditor.prototype.collapse = originalCollapse
+ })
+ })
+ })
+
+ describe("collapse", () => {
+ describe("if collapsed", () => {
+ it("immediatly resolves promise", async () => {
+ editor = getComponent(`
+
+ `)
+ await expect(editor.collapse()).resolves.toBe(
+ "Element is already collapsed."
+ )
+ })
+ })
+
+ describe("if compact", () => {
+ it("immediatly resolves promise", async () => {
+ editor = getComponent(`
+
+ `)
+ await expect(editor.collapse()).resolves.toBe(
+ "Element is already collapsed."
+ )
+ })
+ })
+
+ describe("if fixed", () => {
+ it("immediatly resolves promise", async () => {
+ editor = getComponent(`
+
+ `)
+ await expect(editor.collapse()).resolves.toBe(
+ "Element is already collapsed."
+ )
+ })
+ })
+
+ describe("if expanded", () => {
+ it("collapses element on API then sets to collapsed", async () => {
+ await editor.collapse().then(() => {
+ expect(editor.collapsed).toBeTruthy()
+ })
+ })
+
+ it("collapses nested elements", async () => {
+ editor = getComponent(`
+
+
+
+ `)
+ const nestedElement = document.querySelector("#element_666")
+ await editor.collapse().then(() => {
+ expect(nestedElement.collapsed).toBeTruthy()
+ })
+ })
+
+ it("handles errors", async () => {
+ editor = getComponent(`
+
+ `)
+ global.console = {
+ ...console,
+ error: jest.fn()
+ }
+ await editor.collapse()
+ expect(Alchemy.growl).toHaveBeenCalledWith(
+ "Something went wrong!",
+ "error"
+ )
+ })
+ })
+ })
+
+ describe("expand", () => {
+ describe("if expanded", () => {
+ it("immediatly resolves promise", async () => {
+ editor = getComponent(`
+
+ `)
+ await expect(editor.expand()).resolves.toBe(
+ "Element is already expanded."
+ )
+ })
+ })
+
+ describe("if compact", () => {
+ describe("and has a parent element editor", () => {
+ it("expands parent element", async () => {
+ editor = getComponent(`
+
+
+
+ `)
+ const nestedElement = document.querySelector("#element_123")
+ await nestedElement.expand()
+ expect(editor.expanded).toBeTruthy()
+ })
+ })
+ })
+
+ describe("if collapsed", () => {
+ it("expands element on API then sets to expanded", async () => {
+ editor = getComponent(`
+
+ `)
+ await editor.expand().then(() => {
+ expect(editor.expanded).toBeTruthy()
+ })
+ })
+
+ it("expands parent elements", async () => {
+ editor = getComponent(`
+
+
+
+ `)
+ const nestedElement = document.querySelector("#element_123")
+ await nestedElement.expand().then(() => {
+ expect(editor.expanded).toBeTruthy()
+ })
+ })
+
+ it("handles errors", async () => {
+ editor = getComponent(`
+
+ `)
+ global.console = {
+ ...console,
+ error: jest.fn()
+ }
+ try {
+ await editor.expand()
+ } catch {
+ expect(Alchemy.growl).toHaveBeenCalledWith(
+ "Something went wrong!",
+ "error"
+ )
+ }
+ })
+ })
+ })
+
+ describe("updateTitle", () => {
+ it("sets title", () => {
+ editor.updateTitle("Foo bar")
+ expect(
+ editor.querySelector(".element-header .preview_text_quote").textContent
+ ).toBe("Foo bar")
+ })
+
+ it("dispatches event", () => {
+ return new Promise((resolve) => {
+ editor.addEventListener("alchemy:element-update-title", (event) => {
+ expect(event.detail).toEqual({ title: "Foo bar" })
+ resolve()
+ })
+ editor.updateTitle("Foo bar")
+ })
+ })
+ })
+
+ describe("elementId", () => {
+ it("returns element database id", () => {
+ expect(editor.elementId).toEqual("123")
+ })
+ })
+
+ describe("elementName", () => {
+ it("returns element definition name", () => {
+ expect(editor.elementName).toEqual("article")
+ })
+ })
+
+ describe("compact", () => {
+ it("is false if not has compact attribute", () => {
+ expect(editor.compact).toBeFalsy()
+ })
+
+ it("is true if has compact attribute", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.compact).toBeTruthy()
+ })
+ })
+
+ describe("fixed", () => {
+ it("is false if not has fixed attribute", () => {
+ expect(editor.fixed).toBeFalsy()
+ })
+
+ it("is true if has fixed attribute", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.fixed).toBeTruthy()
+ })
+ })
+
+ describe("expanded", () => {
+ it("is true if not folded", () => {
+ expect(editor.expanded).toBeTruthy()
+ })
+
+ it("is false if folded", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.expanded).toBeFalsy()
+ })
+ })
+
+ describe("collapsed", () => {
+ it("is true if folded", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.collapsed).toBeTruthy()
+ })
+
+ it("is false if not folded", () => {
+ expect(editor.collapsed).toBeFalsy()
+ })
+ })
+
+ describe("dirty", () => {
+ it("is true if dirty class is present", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.dirty).toBeTruthy()
+ })
+
+ it("is false if dirty class is not present", () => {
+ expect(editor.dirty).toBeFalsy()
+ })
+ })
+
+ describe("dirty =", () => {
+ it("sets to dirty if set to true", () => {
+ expect(editor.dirty).toBeFalsy()
+ editor.dirty = true
+ expect(editor.dirty).toBeTruthy()
+ })
+
+ it("sets not dirty if set to false", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.dirty).toBeTruthy()
+ editor.dirty = false
+ expect(editor.dirty).toBeFalsy()
+ })
+ })
+
+ describe("header", () => {
+ it("returns header element", () => {
+ expect(editor.header).toBeInstanceOf(HTMLElement)
+ })
+ })
+
+ describe("body", () => {
+ it("returns body", () => {
+ expect(editor.body).toBeInstanceOf(HTMLElement)
+ })
+
+ it("only returns immediate body", () => {
+ editor = getComponent(`
+
+
+
+ `)
+ expect(editor.body).toBeNull()
+ })
+ })
+
+ describe("footer", () => {
+ it("returns footer", () => {
+ expect(editor.footer).toBeInstanceOf(HTMLElement)
+ })
+
+ it("only returns immediate footer", () => {
+ editor = getComponent(`
+
+
+
+ `)
+ expect(editor.footer).toBeNull()
+ })
+ })
+
+ describe("toggleButton", () => {
+ it("returns toggleButton", () => {
+ expect(editor.toggleButton).toBeInstanceOf(HTMLElement)
+ })
+ })
+
+ describe("toggleIcon", () => {
+ it("returns icon if present", () => {
+ expect(editor.toggleIcon).toBeInstanceOf(HTMLElement)
+ })
+
+ it("returns undefined if not present", () => {
+ editor = getComponent(`
+
+
+
+ `)
+ expect(editor.toggleIcon).toBeUndefined()
+ })
+ })
+
+ describe("errorsDisplay", () => {
+ it("returns errors display element", () => {
+ expect(editor.errorsDisplay).toBeInstanceOf(HTMLElement)
+ })
+ })
+
+ describe("elementErrors", () => {
+ it("returns element errors element", () => {
+ expect(editor.elementErrors).toBeInstanceOf(HTMLElement)
+ })
+ })
+
+ describe("hasEditors", () => {
+ it("returns true if ingredient editors present", () => {
+ expect(editor.hasEditors).toBeTruthy()
+ })
+
+ it("returns false if no ingredient editors present", () => {
+ editor = getComponent(`
+
+ `)
+ expect(editor.hasEditors).toBeFalsy()
+ })
+ })
+
+ describe("hasChildren", () => {
+ it("returns true if nested elements present", () => {
+ editor = getComponent(`
+
+
+
+ `)
+ expect(editor.hasChildren).toBeTruthy()
+ })
+
+ it("returns false if no ingredient editors present", () => {
+ expect(editor.hasChildren).toBeFalsy()
+ })
+ })
+
+ describe("firstChild", () => {
+ it("returns first nested element", () => {
+ editor = getComponent(`
+
+
+
+ `)
+ expect(editor.firstChild).toBeInstanceOf(ElementEditor)
+ })
+ })
+
+ describe("parentElementEditor", () => {
+ it("returns parent element editor", () => {
+ getComponent(`
+
+
+
+ `)
+ editor = document.querySelector("#element_666")
+ expect(editor.parentElementEditor).toBeInstanceOf(ElementEditor)
+ expect(editor.parentElementEditor.id).toBe("element_123")
+ })
+ })
+})
diff --git a/spec/javascript/alchemy_admin/components/ingredient_group.spec.js b/spec/javascript/alchemy_admin/components/ingredient_group.spec.js
new file mode 100644
index 0000000000..ed8461ff88
--- /dev/null
+++ b/spec/javascript/alchemy_admin/components/ingredient_group.spec.js
@@ -0,0 +1,59 @@
+import "alchemy_admin/components/ingredient_group"
+import { renderComponent } from "./component.helper"
+
+describe("alchemy-element-editor", () => {
+ const html = `
+
+ Kicker
+
+ `
+
+ afterEach(() => {
+ localStorage.clear()
+ })
+
+ describe("on click open", () => {
+ it("stores ingredient group in localStorage", () => {
+ const group = renderComponent("alchemy-ingredient-group", html)
+ expect(
+ localStorage.hasOwnProperty("Alchemy.expanded_ingredient_groups")
+ ).toBeFalsy()
+ group.open = true
+ // In the browser this event is triggered on change of the open property, but not in JSdom.
+ // So we need to dispatch it manually
+ const toggle = new Event("toggle")
+ group.dispatchEvent(toggle)
+ expect(
+ localStorage.hasOwnProperty("Alchemy.expanded_ingredient_groups")
+ ).toBeTruthy()
+ })
+ })
+
+ describe("if stored in localStorage", () => {
+ beforeEach(() => {
+ localStorage.setItem(
+ "Alchemy.expanded_ingredient_groups",
+ JSON.stringify(["element_123_ingredient_group_kicker"])
+ )
+ })
+
+ it("opens", () => {
+ const group = renderComponent("alchemy-ingredient-group", html)
+ expect(group.open).toBeTruthy()
+ })
+
+ describe("on click close", () => {
+ it("removes ingredient group from localStorage", () => {
+ const group = renderComponent("alchemy-ingredient-group", html)
+ group.open = false
+ // In the browser this event is triggered on change of the open property, but not in JSdom.
+ // So we need to dispatch it manually
+ const toggle = new Event("toggle")
+ group.dispatchEvent(toggle)
+ expect(localStorage.getItem("Alchemy.expanded_ingredient_groups")).toBe(
+ "[]"
+ )
+ })
+ })
+ })
+})
diff --git a/spec/javascript/alchemy_admin/components/tinymce.spec.js b/spec/javascript/alchemy_admin/components/tinymce.spec.js
index 2c40add735..95c26bf488 100644
--- a/spec/javascript/alchemy_admin/components/tinymce.spec.js
+++ b/spec/javascript/alchemy_admin/components/tinymce.spec.js
@@ -74,4 +74,55 @@ describe("alchemy-tinymce", () => {
expect(tinymceSettings.toolbar).toEqual("bold italic")
})
})
+
+ describe("minHeight", () => {
+ const html = `
+
+
+
+ `
+
+ beforeEach(() => {
+ Alchemy.TinymceDefaults = {
+ toolbar: ["1", "2"],
+ statusbar: true,
+ min_height: 220
+ }
+ })
+
+ it("calculates with default config", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ expect(component.minHeight).toEqual(312.5)
+ })
+
+ it("calculates if toolbar is an array of 1", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ Alchemy.TinymceDefaults.toolbar = ["1"]
+ expect(component.minHeight).toEqual(282.5)
+ })
+
+ it("calculates if another min_height is set in config", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ Alchemy.TinymceDefaults.min_height = 123
+ expect(component.minHeight).toEqual(215.5)
+ })
+
+ it("calculates if toolbar is a string", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ Alchemy.TinymceDefaults.toolbar = "1|2"
+ expect(component.minHeight).toEqual(282.5)
+ })
+
+ it("calculates if toolbar is false", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ Alchemy.TinymceDefaults.toolbar = false
+ expect(component.minHeight).toEqual(251.5)
+ })
+
+ it("calculates if statusbar is false", () => {
+ const component = renderComponent("alchemy-tinymce", html)
+ Alchemy.TinymceDefaults.statusbar = false
+ expect(component.minHeight).toEqual(283)
+ })
+ })
})
diff --git a/spec/models/alchemy/element_spec.rb b/spec/models/alchemy/element_spec.rb
index f536fb274d..bdabd84c56 100644
--- a/spec/models/alchemy/element_spec.rb
+++ b/spec/models/alchemy/element_spec.rb
@@ -609,6 +609,18 @@ module Alchemy
end
end
+ describe "#parent_element_ids" do
+ let(:page) { create(:alchemy_page) }
+
+ let!(:element1) { create(:alchemy_element, page_version: page.draft_version, name: "slider", autogenerate_nested_elements: false) }
+ let!(:element2) { create(:alchemy_element, page_version: page.draft_version, name: "slide", parent_element: element1) }
+ let!(:element3) { create(:alchemy_element, page_version: page.draft_version, name: "slide", parent_element: element2) }
+
+ it "returns parent element ids" do
+ expect(element3.parent_element_ids).to eq([element2.id, element1.id])
+ end
+ end
+
describe ".after_update" do
let(:element) { create(:alchemy_element, page_version: page_version) }
diff --git a/spec/requests/alchemy/admin/elements_controller_spec.rb b/spec/requests/alchemy/admin/elements_controller_spec.rb
index 32473a6482..f24ad82ccd 100644
--- a/spec/requests/alchemy/admin/elements_controller_spec.rb
+++ b/spec/requests/alchemy/admin/elements_controller_spec.rb
@@ -7,15 +7,15 @@
authorize_user(:as_admin)
end
- describe "#fold" do
- context "folded element with ingredients" do
+ describe "#collapse" do
+ context "collapse element with ingredients" do
let(:element) { create(:alchemy_element, :with_ingredients, folded: true) }
context "with validations" do
let(:element) { create(:alchemy_element, :with_ingredients, name: :all_you_can_eat) }
it "saves without running validations" do
- post fold_admin_element_path(id: element.id, format: :js)
+ post collapse_admin_element_path(id: element.id, format: :js)
expect(element.reload).to be_folded
end
end
diff --git a/vendor/assets/javascripts/jquery_plugins/jquery.scrollTo.min.js b/vendor/assets/javascripts/jquery_plugins/jquery.scrollTo.min.js
deleted file mode 100644
index 78e0282802..0000000000
--- a/vendor/assets/javascripts/jquery_plugins/jquery.scrollTo.min.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Copyright (c) 2007-2013 Ariel Flesler - afleslergmailcom | http://flesler.blogspot.com
- * Dual licensed under MIT and GPL.
- * @author Ariel Flesler
- * @version 1.4.5b
- */
-;(function($){var h=$.scrollTo=function(a,b,c){$(window).scrollTo(a,b,c)};h.defaults={axis:'xy',duration:parseFloat($.fn.jquery)>=1.3?0:1,limit:true};h.window=function(a){return $(window)._scrollable()};$.fn._scrollable=function(){return this.map(function(){var a=this,isWin=!a.nodeName||$.inArray(a.nodeName.toLowerCase(),['iframe','#document','html','body'])!=-1;if(!isWin)return a;var b=(a.contentWindow||a).document||a.ownerDocument||a;return/webkit/i.test(navigator.userAgent)||b.compatMode=='BackCompat'?b.body:b.documentElement})};$.fn.scrollTo=function(e,f,g){if(typeof f=='object'){g=f;f=0}if(typeof g=='function')g={onAfter:g};if(e=='max')e=9e9;g=$.extend({},h.defaults,g);f=f||g.duration;g.queue=g.queue&&g.axis.length>1;if(g.queue)f/=2;g.offset=both(g.offset);g.over=both(g.over);return this._scrollable().each(function(){if(e==null)return;var d=this,$elem=$(d),targ=e,toff,attr={},win=$elem.is('html,body');switch(typeof targ){case'number':case'string':if(/^([+-]=?)?\d+(\.\d+)?(px|%)?$/.test(targ)){targ=both(targ);break}targ=$(targ,this);if(!targ.length)return;case'object':if(targ.is||targ.style)toff=(targ=$(targ)).offset()}$.each(g.axis.split(''),function(i,a){var b=a=='x'?'Left':'Top',pos=b.toLowerCase(),key='scroll'+b,old=d[key],max=h.max(d,a);if(toff){attr[key]=toff[pos]+(win?0:old-$elem.offset()[pos]);if(g.margin){attr[key]-=parseInt(targ.css('margin'+b))||0;attr[key]-=parseInt(targ.css('border'+b+'Width'))||0}attr[key]+=g.offset[pos]||0;if(g.over[pos])attr[key]+=targ[a=='x'?'width':'height']()*g.over[pos]}else{var c=targ[pos];attr[key]=c.slice&&c.slice(-1)=='%'?parseFloat(c)/100*max:c}if(g.limit&&/^\d+$/.test(attr[key]))attr[key]=attr[key]<=0?0:Math.min(attr[key],max);if(!i&&g.queue){if(old!=attr[key])animate(g.onAfterFirst);delete attr[key]}});animate(g.onAfter);function animate(a){$elem.animate(attr,f,g.easing,a&&function(){a.call(this,e,g)})}}).end()};h.max=function(a,b){var c=b=='x'?'Width':'Height',scroll='scroll'+c;if(!$(a).is('html,body'))return a[scroll]-$(a)[c.toLowerCase()]();var d='client'+c,html=a.ownerDocument.documentElement,body=a.ownerDocument.body;return Math.max(html[scroll],body[scroll])-Math.min(html[d],body[d])};function both(a){return typeof a=='object'?a:{top:a,left:a}}})(jQuery);