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| %> -
    + - - <% 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 = ` + +
    +
    Lorem ipsum
    + +
    +
    +
    +
      +
      +
      +
      + +
      + ` + 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(` + +
      +
      Lorem Ipsum
      +
      +
      + +
      +
      + `) + }) + + 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(` + +
      +
      Lorem Ipsum
      +
      +
      + + + +
      +
      Child Lorem ipsum
      +
      +
      +
      +
        +
        +
        +
        +
        +
        + `) + 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(` + +
        +
        Lorem ipsum
        +
        +
        +
        +
          +
        • Please enter a value
        • +
        +
        +
        +
        +
        +
        + +
        + `) + 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 - aflesler
          gmailcom | 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);