AlchemyCMS/alchemy_cms

View on GitHub
app/javascript/alchemy_admin/components/element_editor.js

Summary

Maintainability
D
1 day
Test Coverage
import ImageLoader from "alchemy_admin/image_loader"
import fileEditors from "alchemy_admin/file_editors"
import pictureEditors from "alchemy_admin/picture_editors"
import SortableElements from "alchemy_admin/sortable_elements"
import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
import { post } from "alchemy_admin/utils/ajax"
import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
import { growl } from "alchemy_admin/growler"

import "alchemy_admin/components/element_editor/publish_element_button"
import "alchemy_admin/components/element_editor/delete_element_button"

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)
    // We use of @rails/ujs for Rails remote forms
    this.addEventListener("ajax:complete", 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
    }

    // When newly created, focus the element and refresh the preview
    if (this.hasAttribute("created")) {
      this.focusElement()
      this.previewWindow?.refresh().then(() => {
        this.focusElementPreview()
      })
      this.removeAttribute("created")
    }

    // 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`)
    SortableElements(`#${this.id} .nested-elements`)
  }

  handleEvent(event) {
    switch (event.type) {
      case "click":
        const elementEditor = event.target.closest("alchemy-element-editor")
        if (elementEditor === this) {
          this.onClickElement()
        }
        break
      case "ajax:complete":
        if (event.target === this.body) {
          const xhr = event.detail[0]
          event.stopPropagation()
          this.onSaveElement(xhr)
        }
        break
      case "alchemy:element-update-title":
        if (!this.hasEditors && event.target == this.firstChild) {
          this.setTitle(event.detail.title)
        }
        break
      case "change":
        // SortableJS fires a native change event :/
        // and we do not want to set the element editor dirty
        // when this happens
        if (event.target.classList.contains("nested-elements")) {
          return
        }
        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() {
    this.previewWindow?.postMessage({
      message: "Alchemy.focusElement",
      element_id: this.elementId
    })
  }

  onClickElement() {
    this.selectElement()
    this.focusElementPreview()
  }

  /**
   * Sets the element to saved state
   * Updates title
   * JS event bubbling will also update the parents element quote.
   * Shows error messages if ingredient validations fail
   * @argument {XMLHttpRequest} xhr
   */
  onSaveElement(xhr) {
    const data = JSON.parse(xhr.responseText)
    // Reset errors that might be visible from last save attempt
    this.setClean()
    // If validation failed
    if (xhr.status === 422) {
      const warning = data.warning
      // Create error messages
      // Mark ingredients as failed
      data.ingredientsWithErrors.forEach((ingredient) => {
        const ingredientEditor = this.querySelector(
          `[data-ingredient-id="${ingredient.id}"]`
        )
        const errorDisplay = createHtmlElement(
          `<small class="error">${ingredient.errorMessage}</small>`
        )
        ingredientEditor?.appendChild(errorDisplay)
        ingredientEditor?.classList.add("validation_failed")
      })
      // Show message
      growl(warning, "warn")
      this.elementErrors.classList.remove("hidden")
    } else {
      growl(data.notice)
      this.previewWindow?.refresh().then(() => {
        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
   */
  setClean() {
    this.dirty = false
    window.onbeforeunload = null
    this.elementErrors.classList.add("hidden")

    if (this.hasEditors) {
      this.body.querySelectorAll(".ingredient-editor").forEach((el) => {
        el.classList.remove("dirty", "validation_failed")
        el.querySelectorAll("small.error").forEach((e) => e.remove())
      })
    }
  }

  /**
   * Sets the element into dirty (unsafed) state
   */
  setDirty() {
    if (this.hasEditors) {
      this.dirty = 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) => {
        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) => {
            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 }
      })
    )
  }

  /**
   * Sets element published or hidden
   * @param {boolean}
   */
  set published(isPublished) {
    if (isPublished) {
      this.classList.remove("element-hidden")
    } else {
      this.classList.add("element-hidden")
    }
  }

  /**
   * Is element published or hidden
   * @returns {boolean}
   */
  get published() {
    return !this.classList.contains("hidden")
  }

  /**
   * @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 &&
      (this.toggleIcon.name = value ? "arrow-left-s" : "arrow-down-s")
  }

  /**
   * @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("alchemy-icon")
  }

  /**
   * 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")
  }

  get previewWindow() {
    return document.getElementById("alchemy_preview_window")
  }
}

customElements.define("alchemy-element-editor", ElementEditor)