From 7221f54ae175fff2df9197860402d24f0542ae95 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 24 Sep 2024 08:47:18 +0200 Subject: [PATCH 1/5] Convert Alchemy.ImageOverlay into ES module The last module before we can convert it's parent the Dialog. --- app/assets/javascripts/alchemy/admin.js | 1 - .../alchemy/alchemy.image_overlay.coffee | 54 --------------- app/javascript/alchemy_admin.js | 2 - app/javascript/alchemy_admin/image_overlay.js | 68 +++++++++++++++++++ .../alchemy/admin/pictures/index.html.erb | 19 +++--- 5 files changed, 78 insertions(+), 66 deletions(-) delete mode 100644 app/assets/javascripts/alchemy/alchemy.image_overlay.coffee create mode 100644 app/javascript/alchemy_admin/image_overlay.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index 70184662cb..05731c78ed 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -1,4 +1,3 @@ // Alchemy CMS Sprockets Manifest // ------------------------------ //= require alchemy/alchemy.dialog -//= require alchemy/alchemy.image_overlay diff --git a/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee b/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee deleted file mode 100644 index 38b0fa3b68..0000000000 --- a/app/assets/javascripts/alchemy/alchemy.image_overlay.coffee +++ /dev/null @@ -1,54 +0,0 @@ -class window.Alchemy.ImageOverlay extends Alchemy.Dialog - - constructor: (url) -> - super(url, @options) - return - - init: -> - Alchemy.ImageLoader(@dialog_body[0]) - $('.zoomed-picture-background').on "click", (e) => - e.stopPropagation() - return if e.target.nodeName == 'IMG' - @close() - false - $('.picture-overlay-handle').on "click", (e) => - @dialog.toggleClass('hide-form') - false - @$previous = $('.previous-picture') - @$next = $('.next-picture') - @$document.keydown (e) => - if e.target.nodeName == 'INPUT' || e.target.nodeName == 'TEXTAREA' - return true - switch e.which - when 37 - @previous() - false - when 39 - @next() - false - else - true - super() - - previous: -> - @$previous[0]?.click() - return - - next: -> - @$next[0]?.click() - return - - build: -> - @dialog_container = $('
') - @dialog = $('
') - @dialog_body = $('
') - @close_button = $(' - - ') - @dialog.append(@close_button) - @dialog.append(@dialog_body) - @dialog_container.append(@dialog) - @overlay = $('
') - @$body.append(@overlay) - @$body.append(@dialog_container) - @dialog diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index bbf662be88..d68b3582fc 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -15,7 +15,6 @@ import { growl } from "alchemy_admin/growler" import ImageLoader from "alchemy_admin/image_loader" import Initializer from "alchemy_admin/initializer" import { LinkDialog } from "alchemy_admin/link_dialog" -import pictureSelector from "alchemy_admin/picture_selector" import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay" import Sitemap from "alchemy_admin/sitemap" import Spinner from "alchemy_admin/spinner" @@ -49,7 +48,6 @@ Object.assign(Alchemy, { growl, ImageLoader: ImageLoader.init, LinkDialog, - pictureSelector, pleaseWaitOverlay, Sitemap, Spinner, diff --git a/app/javascript/alchemy_admin/image_overlay.js b/app/javascript/alchemy_admin/image_overlay.js new file mode 100644 index 0000000000..2320bf77b2 --- /dev/null +++ b/app/javascript/alchemy_admin/image_overlay.js @@ -0,0 +1,68 @@ +import ImageLoader from "alchemy_admin/image_loader" + +export default class ImageOverlay extends Alchemy.Dialog { + constructor(url) { + super(url) + } + + init() { + ImageLoader.init(this.dialog_body[0]) + $(".zoomed-picture-background").on("click", (e) => { + e.stopPropagation() + if (e.target.nodeName === "IMG") { + return + } + this.close() + return false + }) + $(".picture-overlay-handle").on("click", (e) => { + this.dialog.toggleClass("hide-form") + return false + }) + this.$previous = $(".previous-picture") + this.$next = $(".next-picture") + this.$document.keydown((e) => { + if (e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA") { + return true + } + switch (e.which) { + case 37: + this.previous() + return false + case 39: + this.next() + return false + default: + return true + } + }) + super.init() + } + + previous() { + if (this.$previous[0] != null) { + this.$previous[0].click() + } + } + + next() { + if (this.$next[0] != null) { + this.$next[0].click() + } + } + + build() { + this.dialog_container = $('
') + this.dialog = $('
') + this.dialog_body = $('
') + this.close_button = $(` + + `) + this.dialog.append(this.close_button) + this.dialog.append(this.dialog_body) + this.dialog_container.append(this.dialog) + this.overlay = $('
') + this.$body.append(this.overlay) + this.$body.append(this.dialog_container) + } +} diff --git a/app/views/alchemy/admin/pictures/index.html.erb b/app/views/alchemy/admin/pictures/index.html.erb index 45c83be5db..8bd55a43e0 100644 --- a/app/views/alchemy/admin/pictures/index.html.erb +++ b/app/views/alchemy/admin/pictures/index.html.erb @@ -87,15 +87,16 @@ <% content_for :javascripts do %> <% end %> From 3e3ad98484e1821f4552a308446104f7bfcdeb56 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 24 Sep 2024 08:44:10 +0200 Subject: [PATCH 2/5] Convert Alchemy.Dialog into ES module The last CoffeeScript file and last JS file we compile with Sprockets. We can now remove Sprockets. --- app/assets/javascripts/alchemy/admin.js | 1 - .../alchemy/alchemy.dialog.js.coffee | 271 ------------- app/javascript/alchemy_admin.js | 3 + .../alchemy_admin/components/dialog_link.js | 16 +- app/javascript/alchemy_admin/dialog.js | 358 ++++++++++++++++++ app/javascript/alchemy_admin/gui.js | 5 +- app/javascript/alchemy_admin/hotkeys.js | 5 +- app/javascript/alchemy_admin/image_overlay.js | 3 +- app/javascript/alchemy_admin/link_dialog.js | 3 +- .../alchemy_admin/picture_selector.js | 3 +- .../components/dialog_link.spec.js | 14 +- 11 files changed, 379 insertions(+), 303 deletions(-) delete mode 100644 app/assets/javascripts/alchemy/alchemy.dialog.js.coffee create mode 100644 app/javascript/alchemy_admin/dialog.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index 05731c78ed..d0d8c99055 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -1,3 +1,2 @@ // Alchemy CMS Sprockets Manifest // ------------------------------ -//= require alchemy/alchemy.dialog diff --git a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee deleted file mode 100644 index cd2d862544..0000000000 --- a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +++ /dev/null @@ -1,271 +0,0 @@ -# Dialog windows -# -class window.Alchemy.Dialog - - DEFAULTS: - header_height: 36 - size: '400x300' - padding: true - title: '' - modal: true - overflow: 'visible' - ready: -> - closed: -> - - # Arguments: - # - url: The url to load the content from via ajax - # - options: A object holding options - # - size: The maximum size of the Dialog - # - title: The title of the Dialog - constructor: (@url, @options = {}) -> - @options = $.extend({}, @DEFAULTS, @options) - @$document = $(document) - @$window = $(window) - @$body = $('body') - size = @options.size.split('x') - @width = parseInt(size[0], 10) - @height = parseInt(size[1], 10) - @build() - - # Opens the Dialog and loads the content via ajax. - open: -> - @dialog.trigger 'Alchemy.DialogOpen' - @bind_close_events() - window.requestAnimationFrame => - @dialog_container.addClass('open') - @overlay.addClass('open') if @overlay? - @$body.addClass('prevent-scrolling') - Alchemy.currentDialogs.push(this) - @load() - true - - # Closes the Dialog and removes it from the DOM - close: -> - @dialog.trigger 'DialogClose.Alchemy' - @$document.off 'keydown' - @dialog_container.removeClass('open') - @overlay.removeClass('open') if @overlay? - @$document.on 'webkitTransitionEnd transitionend oTransitionEnd', => - @$document.off 'webkitTransitionEnd transitionend oTransitionEnd' - @dialog_container.remove() - @overlay.remove() if @overlay? - @$body.removeClass('prevent-scrolling') - Alchemy.currentDialogs.pop(this) - if @options.closed? - @options.closed() - true - - # Loads the content via ajax and replaces the Dialog body with server response. - load: -> - @show_spinner() - $.get @url, (data) => - @replace(data) - .fail (xhr) => - @show_error(xhr) - true - - # Reloads the Dialog content - reload: -> - @dialog_body.empty() - @load() - - # Replaces the dialog body with given content and initializes it. - replace: (data) -> - @remove_spinner() - @dialog_body.hide() - @dialog_body.html(data) - @init() - @dialog[0].dispatchEvent(new CustomEvent( - "DialogReady.Alchemy", - bubbles: true - detail: - body: @dialog_body[0] - )) - if @options.ready? - @options.ready(@dialog_body) - @dialog_body.show() - true - - # Adds a spinner into Dialog body - show_spinner: -> - @spinner = new Alchemy.Spinner('medium') - @spinner.spin(@dialog_body[0]) - - # Removes the spinner from Dialog body - remove_spinner: -> - @spinner.stop() - - # Initializes the Dialog body - init: -> - Alchemy.GUI.init(@dialog_body) - @watch_remote_forms() - - # Watches ajax requests inside of dialog body and replaces the content accordingly - watch_remote_forms: -> - form = $('[data-remote="true"]', @dialog_body) - form.bind "ajax:success", (event) => - xhr = event.detail[2] - content_type = xhr.getResponseHeader('Content-Type') - if content_type.match(/javascript/) - return - else - @dialog_body.html(xhr.responseText) - @init() - return - form.bind "ajax:error", (event, b, c) => - statusText = event.detail[1] - xhr = event.detail[2] - @show_error(xhr, statusText) - return - - # Displays an error message - show_error: (xhr, status_message, $container = @dialog_body) -> - error_type = "warning" - switch xhr.status - when 0 - error_header = "The server does not respond." - error_body = "Please check server and try again." - when 403 - error_header = "You are not authorized!" - error_body = "Please close this window." - when 422 - @dialog_body.html(xhr.responseText) - @init() - return - else - error_type = "error" - if status_message - error_header = status_message - console.error(xhr.responseText) - else - error_header = "#{xhr.statusText} (#{xhr.status})" - error_body = "Please check log and try again." - $errorDiv = $(" -

#{error_header}

-

#{error_body}

-
") - $container.html $errorDiv - - # Binds close events on: - # - Close button - # - Overlay (if the Dialog is a modal) - # - ESC Key - bind_close_events: -> - @close_button.on "click", => - @close() - false - @dialog_container.addClass('closable').on "click", (e) => - return true if e.target != @dialog_container.get(0) - @close() - false - @$document.keydown (e) => - if e.which == 27 - @close() - false - else - true - - # Builds the html structure of the Dialog - build: -> - @dialog_container = $('
') - @dialog = $('
') - @dialog_body = $('
') - @dialog_header = $('
') - @dialog_title = $('
') - @close_button = $('') - @dialog_title.text(@options.title) - @dialog_header.append(@dialog_title) - @dialog_header.append(@close_button) - @dialog.append(@dialog_header) - @dialog.append(@dialog_body) - @dialog_container.append(@dialog) - @dialog.addClass('modal') if @options.modal - @dialog_body.addClass('padded') if @options.padding - if @options.modal - @overlay = $('
') - @$body.append(@overlay) - @$body.append(@dialog_container) - @resize() - @dialog - - # Sets the correct size of the dialog - # It normalizes the given size, so that it never acceeds the window size. - resize: -> - padding = 16 - $doc_width = @$window.width() - $doc_height = @$window.height() - if @options.size == 'fullscreen' - [@width, @height] = [$doc_width, $doc_height] - if @width >= $doc_width - @width = $doc_width - padding - if @height >= $doc_height - @height = $doc_height - padding - @DEFAULTS.header_height - @dialog.css - 'width': @width - 'min-height': @height - overflow: @options.overflow - if @options.overflow == 'hidden' - @dialog_body.css - height: @height - overflow: 'auto' - else - @dialog_body.css - 'min-height': @height - overflow: 'visible' - return - -# Collection of all current dialog instances -window.Alchemy.currentDialogs = [] - -# Gets the last dialog instantiated, which is the current one. -window.Alchemy.currentDialog = -> - length = Alchemy.currentDialogs.length - return if length == 0 - Alchemy.currentDialogs[length - 1] - -# Utility function to close the current Dialog -# -# You can pass a callback function, that gets triggered after the Dialog gets closed. -# -window.Alchemy.closeCurrentDialog = (callback) -> - dialog = Alchemy.currentDialog() - if dialog? - dialog.options.closed = callback - dialog.close() - -# Utility function to open a new Dialog -window.Alchemy.openDialog = (url, options) -> - if !url - throw('No url given! Please provide an url.') - dialog = new Alchemy.Dialog(url, options) - dialog.open() - -# Watches elements for Alchemy Dialogs -# -# Links having a data-alchemy-confirm-delete -# and input/buttons having a data-alchemy-confirm attribute get watched. -# -# You can pass a scope so that only elements inside this scope are queried. -# -# The href attribute of the link is the url for the overlay window. -# -# See Alchemy.Dialog for further options you can add to the data attribute -# -window.Alchemy.watchForDialogs = (scope = '#alchemy') -> - $(scope).on 'click', '[data-alchemy-confirm-delete]', (event) -> - $this = $(this) - options = $this.data('alchemy-confirm-delete') - Alchemy.confirmToDeleteDialog($this.attr('href'), options) - event.preventDefault() - return - $(scope).on 'click', '[data-alchemy-confirm]', (event) -> - options = $(this).data('alchemy-confirm') - Alchemy.openConfirmDialog options.message, $.extend options, - ok_label: options.ok_label - cancel_label: options.cancel_label - on_ok: => - Alchemy.pleaseWaitOverlay() - @form.submit() - return - event.preventDefault() - return diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index d68b3582fc..d1f39ed730 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -9,6 +9,7 @@ import Rails from "@rails/ujs" import GUI from "alchemy_admin/gui" import { translate } from "alchemy_admin/i18n" +import { currentDialog, closeCurrentDialog } from "alchemy_admin/dialog" import Dirty from "alchemy_admin/dirty" import * as FixedElements from "alchemy_admin/fixed_elements" import { growl } from "alchemy_admin/growler" @@ -41,6 +42,8 @@ if (typeof window.Alchemy === "undefined") { // Enhance the global Alchemy object with imported features Object.assign(Alchemy, { + closeCurrentDialog, + currentDialog, ...Dirty, GUI, t: translate, // Global utility method for translating a given string diff --git a/app/javascript/alchemy_admin/components/dialog_link.js b/app/javascript/alchemy_admin/components/dialog_link.js index 692817075b..9504b0c0e2 100644 --- a/app/javascript/alchemy_admin/components/dialog_link.js +++ b/app/javascript/alchemy_admin/components/dialog_link.js @@ -1,13 +1,4 @@ -export const DEFAULTS = { - header_height: 36, - size: "400x300", - padding: true, - title: "", - modal: true, - overflow: "visible", - ready: () => {}, - closed: () => {} -} +import { DEFAULTS, Dialog } from "alchemy_admin/dialog" export class DialogLink extends HTMLAnchorElement { constructor() { @@ -23,10 +14,7 @@ export class DialogLink extends HTMLAnchorElement { } openDialog() { - this.dialog = new Alchemy.Dialog( - this.getAttribute("href"), - this.dialogOptions - ) + this.dialog = new Dialog(this.getAttribute("href"), this.dialogOptions) this.dialog.open() } diff --git a/app/javascript/alchemy_admin/dialog.js b/app/javascript/alchemy_admin/dialog.js new file mode 100644 index 0000000000..b48826f76e --- /dev/null +++ b/app/javascript/alchemy_admin/dialog.js @@ -0,0 +1,358 @@ +import { + confirmToDeleteDialog, + openConfirmDialog +} from "alchemy_admin/confirm_dialog" + +import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay" +import Spinner from "alchemy_admin/spinner" + +// Collection of all current dialog instances +const currentDialogs = [] + +export const DEFAULTS = { + header_height: 36, + size: "400x300", + padding: true, + title: "", + modal: true, + overflow: "visible", + ready: () => {}, + closed: () => {} +} + +export class Dialog { + // Arguments: + // - url: The url to load the content from via ajax + // - options: A object holding options + // - size: The maximum size of the Dialog + // - title: The title of the Dialog + constructor(url, options) { + this.url = url + if (options == null) { + options = {} + } + this.options = options + this.options = $.extend({}, DEFAULTS, this.options) + this.$document = $(document) + this.$window = $(window) + this.$body = $("body") + const size = this.options.size.split("x") + this.width = parseInt(size[0], 10) + this.height = parseInt(size[1], 10) + this.build() + } + + // Opens the Dialog and loads the content via ajax. + open() { + this.dialog.trigger("Alchemy.DialogOpen") + this.bind_close_events() + window.requestAnimationFrame(() => { + this.dialog_container.addClass("open") + if (this.overlay != null) { + return this.overlay.addClass("open") + } + }) + this.$body.addClass("prevent-scrolling") + currentDialogs.push(this) + this.load() + } + + // Closes the Dialog and removes it from the DOM + close() { + this.dialog.trigger("DialogClose.Alchemy") + this.$document.off("keydown") + this.dialog_container.removeClass("open") + if (this.overlay != null) { + this.overlay.removeClass("open") + } + this.$document.on( + "webkitTransitionEnd transitionend oTransitionEnd", + () => { + this.$document.off("webkitTransitionEnd transitionend oTransitionEnd") + this.dialog_container.remove() + if (this.overlay != null) { + this.overlay.remove() + } + this.$body.removeClass("prevent-scrolling") + currentDialogs.pop(this) + if (this.options.closed != null) { + return this.options.closed() + } + } + ) + return true + } + + // Loads the content via ajax and replaces the Dialog body with server response. + load() { + this.show_spinner() + $.get(this.url, (data) => { + this.replace(data) + }).fail((xhr) => { + this.show_error(xhr) + }) + } + + // Reloads the Dialog content + reload() { + this.dialog_body.empty() + this.load() + } + + // Replaces the dialog body with given content and initializes it. + replace(data) { + this.remove_spinner() + this.dialog_body.hide() + this.dialog_body.html(data) + this.init() + this.dialog[0].dispatchEvent( + new CustomEvent("DialogReady.Alchemy", { + bubbles: true, + detail: { + body: this.dialog_body[0] + } + }) + ) + if (this.options.ready != null) { + this.options.ready(this.dialog_body) + } + this.dialog_body.show() + } + + // Adds a spinner into Dialog body + show_spinner() { + this.spinner = new Spinner("medium") + this.spinner.spin(this.dialog_body[0]) + } + + // Removes the spinner from Dialog body + remove_spinner() { + this.spinner.stop() + } + + // Initializes the Dialog body + init() { + Alchemy.GUI.init(this.dialog_body) + this.watch_remote_forms() + } + + // Watches ajax requests inside of dialog body and replaces the content accordingly + watch_remote_forms() { + const $form = $('[data-remote="true"]', this.dialog_body) + + $form.on("ajax:success", (event) => { + const xhr = event.detail[2] + const content_type = xhr.getResponseHeader("Content-Type") + if (content_type.match(/javascript/)) { + return + } else { + this.dialog_body.html(xhr.responseText) + this.init() + } + }) + + $form.on("ajax:error", (event) => { + const statusText = event.detail[1] + const xhr = event.detail[2] + this.show_error(xhr, statusText) + }) + } + + // Displays an error message + show_error(xhr, status_message, $container) { + let error_body, + error_header, + error_type = "warning" + + if ($container == null) { + $container = this.dialog_body + } + + switch (xhr.status) { + case 0: + error_header = "The server does not respond." + error_body = "Please check server and try again." + break + case 403: + error_header = "You are not authorized!" + error_body = "Please close this window." + break + case 422: + this.dialog_body.html(xhr.responseText) + this.init() + return + default: + error_type = "error" + if (status_message) { + error_header = status_message + console.error(xhr.responseText) + } else { + error_header = `${xhr.statusText} (${xhr.status})` + } + error_body = "Please check log and try again." + } + + const $errorDiv = $(` +

${error_header}

+

${error_body}

+
`) + + $container.html($errorDiv) + } + + // Binds close events on: + // - Close button + // - Overlay (if the Dialog is a modal) + // - ESC Key + bind_close_events() { + this.close_button.on("click", () => { + this.close() + }) + this.dialog_container.addClass("closable").on("click", (e) => { + if (e.target !== this.dialog_container.get(0)) { + return true + } + this.close() + return false + }) + this.$document.keydown((e) => { + if (e.which === 27) { + this.close() + return false + } else { + return true + } + }) + } + + // Builds the html structure of the Dialog + build() { + this.dialog_container = $('
') + this.dialog = $('
') + this.dialog_body = $('
') + this.dialog_header = $('
') + this.dialog_title = $('
') + this.close_button = $( + '' + ) + this.dialog_title.text(this.options.title) + this.dialog_header.append(this.dialog_title) + this.dialog_header.append(this.close_button) + this.dialog.append(this.dialog_header) + this.dialog.append(this.dialog_body) + this.dialog_container.append(this.dialog) + if (this.options.modal) { + this.dialog.addClass("modal") + } + if (this.options.padding) { + this.dialog_body.addClass("padded") + } + if (this.options.modal) { + this.overlay = $('
') + this.$body.append(this.overlay) + } + this.$body.append(this.dialog_container) + this.resize() + } + + // Sets the correct size of the dialog + // It normalizes the given size, so that it never acceeds the window size. + resize() { + const padding = 16 + const $doc_width = this.$window.width() + const $doc_height = this.$window.height() + if (this.options.size === "fullscreen") { + ;[this.width, this.height] = Array.from([$doc_width, $doc_height]) + } + if (this.width >= $doc_width) { + this.width = $doc_width - padding + } + if (this.height >= $doc_height) { + this.height = $doc_height - padding - DEFAULTS.header_height + } + this.dialog.css({ + width: this.width, + "min-height": this.height, + overflow: this.options.overflow + }) + if (this.options.overflow === "hidden") { + this.dialog_body.css({ + height: this.height, + overflow: "auto" + }) + } else { + this.dialog_body.css({ + "min-height": this.height, + overflow: "visible" + }) + } + } +} + +// Gets the last dialog instantiated, which is the current one. +export function currentDialog() { + const { length } = currentDialogs + if (length === 0) { + return + } + return currentDialogs[length - 1] +} + +// Utility function to close the current Dialog +// +// You can pass a callback function, that gets triggered after the Dialog gets closed. +// +export function closeCurrentDialog(callback) { + const dialog = currentDialog() + if (dialog != null) { + dialog.options.closed = callback + return dialog.close() + } +} + +// Utility function to open a new Dialog +export function openDialog(url, options) { + if (!url) { + throw "No url given! Please provide an url." + } + const dialog = new Dialog(url, options) + dialog.open() +} + +// Watches elements for Alchemy Dialogs +// +// Links having a data-alchemy-confirm-delete +// and input/buttons having a data-alchemy-confirm attribute get watched. +// +// You can pass a scope so that only elements inside this scope are queried. +// +// The href attribute of the link is the url for the overlay window. +// +// See Dialog for further options you can add to the data attribute. +// +export function watchForDialogs(scope) { + if (scope == null) { + scope = "#alchemy" + } + $(scope).on("click", "[data-alchemy-confirm-delete]", function (event) { + const $this = $(this) + const options = $this.data("alchemy-confirm-delete") + confirmToDeleteDialog($this.attr("href"), options) + event.preventDefault() + }) + $(scope).on("click", "[data-alchemy-confirm]", function (event) { + const options = $(this).data("alchemy-confirm") + openConfirmDialog( + options.message, + $.extend(options, { + ok_label: options.ok_label, + cancel_label: options.cancel_label, + on_ok: () => { + pleaseWaitOverlay() + this.form.submit() + } + }) + ) + event.preventDefault() + }) +} diff --git a/app/javascript/alchemy_admin/gui.js b/app/javascript/alchemy_admin/gui.js index 208f2e3870..6dc94b49d3 100644 --- a/app/javascript/alchemy_admin/gui.js +++ b/app/javascript/alchemy_admin/gui.js @@ -1,8 +1,9 @@ +import { watchForDialogs } from "alchemy_admin/dialog" import Hotkeys from "alchemy_admin/hotkeys" -function init(scope) { +export function init(scope) { if (!scope) { - Alchemy.watchForDialogs() + watchForDialogs() } Hotkeys(scope) } diff --git a/app/javascript/alchemy_admin/hotkeys.js b/app/javascript/alchemy_admin/hotkeys.js index 5274040c7f..84c4386f9c 100644 --- a/app/javascript/alchemy_admin/hotkeys.js +++ b/app/javascript/alchemy_admin/hotkeys.js @@ -1,4 +1,5 @@ import "keymaster" +import { openDialog } from "alchemy_admin/dialog" const bindedHotkeys = [] @@ -7,7 +8,7 @@ function showHelp(evt) { !$(evt.target).is("input, textarea") && String.fromCharCode(evt.which) === "?" ) { - Alchemy.openDialog("/admin/help", { + openDialog("/admin/help", { title: Alchemy.t("help"), size: "400x492" }) @@ -18,7 +19,7 @@ function showHelp(evt) { } export default function (scope = document) { - // The scope can be a jQuery object because we still use jQuery in Alchemy.Dialog. + // The scope can be a jQuery object because we still use jQuery in alchemy_admin/dialog.js. if (scope instanceof jQuery) { scope = scope[0] } diff --git a/app/javascript/alchemy_admin/image_overlay.js b/app/javascript/alchemy_admin/image_overlay.js index 2320bf77b2..b78caab6e9 100644 --- a/app/javascript/alchemy_admin/image_overlay.js +++ b/app/javascript/alchemy_admin/image_overlay.js @@ -1,6 +1,7 @@ import ImageLoader from "alchemy_admin/image_loader" +import { Dialog } from "alchemy_admin/dialog" -export default class ImageOverlay extends Alchemy.Dialog { +export default class ImageOverlay extends Dialog { constructor(url) { super(url) } diff --git a/app/javascript/alchemy_admin/link_dialog.js b/app/javascript/alchemy_admin/link_dialog.js index 8fc5adf0ff..dafad879b2 100644 --- a/app/javascript/alchemy_admin/link_dialog.js +++ b/app/javascript/alchemy_admin/link_dialog.js @@ -1,9 +1,10 @@ import { translate } from "alchemy_admin/i18n" +import { Dialog } from "alchemy_admin/dialog" // Represents the link Dialog that appears, if a user clicks the link buttons // in TinyMCE or on an Ingredient that has links enabled (e.g. Picture) // -export class LinkDialog extends Alchemy.Dialog { +export class LinkDialog extends Dialog { #onCreateLink constructor(link) { diff --git a/app/javascript/alchemy_admin/picture_selector.js b/app/javascript/alchemy_admin/picture_selector.js index 3088efff26..26409b440d 100644 --- a/app/javascript/alchemy_admin/picture_selector.js +++ b/app/javascript/alchemy_admin/picture_selector.js @@ -1,4 +1,5 @@ import { on } from "alchemy_admin/utils/events" +import { openDialog } from "alchemy_admin/dialog" function toggleCheckboxes(state) { document @@ -58,7 +59,7 @@ export default function PictureSelector() { const url = editMultiplePicturesUrl(event.target.href) - Alchemy.openDialog(url, { + openDialog(url, { title: event.target.title, size: "400x295" }) diff --git a/spec/javascript/alchemy_admin/components/dialog_link.spec.js b/spec/javascript/alchemy_admin/components/dialog_link.spec.js index e1ee03c5df..ec3196e781 100644 --- a/spec/javascript/alchemy_admin/components/dialog_link.spec.js +++ b/spec/javascript/alchemy_admin/components/dialog_link.spec.js @@ -1,16 +1,10 @@ import "alchemy_admin/components/dialog_link" -import { DEFAULTS } from "alchemy_admin/components/dialog_link" +import { Dialog, DEFAULTS } from "alchemy_admin/dialog" import { renderComponent } from "./component.helper" -class Dialog { - open() {} -} - -beforeEach(() => { - global.Alchemy = { - Dialog: Dialog - } -}) +// import jquery and append it to the window object +import jQuery from "jquery" +globalThis.$ = jQuery describe("alchemy-dialog-link", () => { it("opens a dialog on click", () => { From e63f2660b004faecaefe97c0b5017cc18557f6cf Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 24 Sep 2024 08:51:08 +0200 Subject: [PATCH 3/5] Remove the Sprockets manifest We do not compile any JS with Sprockets anymore \o/ --- app/assets/javascripts/alchemy/admin.js | 2 -- spec/dummy/vendor/assets/javascripts/alchemy/admin/all.js | 1 - 2 files changed, 3 deletions(-) delete mode 100644 app/assets/javascripts/alchemy/admin.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js deleted file mode 100644 index d0d8c99055..0000000000 --- a/app/assets/javascripts/alchemy/admin.js +++ /dev/null @@ -1,2 +0,0 @@ -// Alchemy CMS Sprockets Manifest -// ------------------------------ diff --git a/spec/dummy/vendor/assets/javascripts/alchemy/admin/all.js b/spec/dummy/vendor/assets/javascripts/alchemy/admin/all.js index 12f825fb9d..9147aac908 100644 --- a/spec/dummy/vendor/assets/javascripts/alchemy/admin/all.js +++ b/spec/dummy/vendor/assets/javascripts/alchemy/admin/all.js @@ -8,5 +8,4 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require alchemy/admin //= require alchemy_i18n/de From 3389b038d283b914c65ae6c44a8aaf26cf7e212e Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 24 Sep 2024 09:57:36 +0200 Subject: [PATCH 4/5] Watch for confirm dialogs in Initializer module Alchemy.GUI.init is only used in this module. Combining it into the Initializer allows to reduce the dependencies of the Dialog class. --- app/javascript/alchemy_admin.js | 2 - app/javascript/alchemy_admin/dialog.js | 47 +----------------- app/javascript/alchemy_admin/gui.js | 13 ----- app/javascript/alchemy_admin/initializer.js | 53 ++++++++++++++++++++- 4 files changed, 53 insertions(+), 62 deletions(-) delete mode 100644 app/javascript/alchemy_admin/gui.js diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index d1f39ed730..1b09bfe063 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -7,7 +7,6 @@ import "select2" import Rails from "@rails/ujs" -import GUI from "alchemy_admin/gui" import { translate } from "alchemy_admin/i18n" import { currentDialog, closeCurrentDialog } from "alchemy_admin/dialog" import Dirty from "alchemy_admin/dirty" @@ -45,7 +44,6 @@ Object.assign(Alchemy, { closeCurrentDialog, currentDialog, ...Dirty, - GUI, t: translate, // Global utility method for translating a given string FixedElements, growl, diff --git a/app/javascript/alchemy_admin/dialog.js b/app/javascript/alchemy_admin/dialog.js index b48826f76e..74b083f33e 100644 --- a/app/javascript/alchemy_admin/dialog.js +++ b/app/javascript/alchemy_admin/dialog.js @@ -1,9 +1,4 @@ -import { - confirmToDeleteDialog, - openConfirmDialog -} from "alchemy_admin/confirm_dialog" - -import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay" +import Hotkeys from "alchemy_admin/hotkeys" import Spinner from "alchemy_admin/spinner" // Collection of all current dialog instances @@ -132,7 +127,7 @@ export class Dialog { // Initializes the Dialog body init() { - Alchemy.GUI.init(this.dialog_body) + Hotkeys(this.dialog_body) this.watch_remote_forms() } @@ -318,41 +313,3 @@ export function openDialog(url, options) { const dialog = new Dialog(url, options) dialog.open() } - -// Watches elements for Alchemy Dialogs -// -// Links having a data-alchemy-confirm-delete -// and input/buttons having a data-alchemy-confirm attribute get watched. -// -// You can pass a scope so that only elements inside this scope are queried. -// -// The href attribute of the link is the url for the overlay window. -// -// See Dialog for further options you can add to the data attribute. -// -export function watchForDialogs(scope) { - if (scope == null) { - scope = "#alchemy" - } - $(scope).on("click", "[data-alchemy-confirm-delete]", function (event) { - const $this = $(this) - const options = $this.data("alchemy-confirm-delete") - confirmToDeleteDialog($this.attr("href"), options) - event.preventDefault() - }) - $(scope).on("click", "[data-alchemy-confirm]", function (event) { - const options = $(this).data("alchemy-confirm") - openConfirmDialog( - options.message, - $.extend(options, { - ok_label: options.ok_label, - cancel_label: options.cancel_label, - on_ok: () => { - pleaseWaitOverlay() - this.form.submit() - } - }) - ) - event.preventDefault() - }) -} diff --git a/app/javascript/alchemy_admin/gui.js b/app/javascript/alchemy_admin/gui.js deleted file mode 100644 index 6dc94b49d3..0000000000 --- a/app/javascript/alchemy_admin/gui.js +++ /dev/null @@ -1,13 +0,0 @@ -import { watchForDialogs } from "alchemy_admin/dialog" -import Hotkeys from "alchemy_admin/hotkeys" - -export function init(scope) { - if (!scope) { - watchForDialogs() - } - Hotkeys(scope) -} - -export default { - init -} diff --git a/app/javascript/alchemy_admin/initializer.js b/app/javascript/alchemy_admin/initializer.js index 5b5d67c950..ede5622423 100644 --- a/app/javascript/alchemy_admin/initializer.js +++ b/app/javascript/alchemy_admin/initializer.js @@ -1,3 +1,11 @@ +import { + confirmToDeleteDialog, + openConfirmDialog +} from "alchemy_admin/confirm_dialog" + +import Hotkeys from "alchemy_admin/hotkeys" +import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay" + /** * add change listener to select to redirect the user after selecting another locale or site * @param {string} selectId @@ -18,12 +26,53 @@ function selectHandler(selectId, parameterName, forcedReload = false) { }) } +// Watches elements for Alchemy Dialogs +// +// Links having a data-alchemy-confirm-delete +// and input/buttons having a data-alchemy-confirm attribute get watched. +// +// You can pass a scope so that only elements inside this scope are queried. +// +// The href attribute of the link is the url for the overlay window. +// +// See Dialog for further options you can add to the data attribute. +// +function watchForConfirmDialogs(scope) { + if (scope == null) { + scope = "#alchemy" + } + $(scope).on("click", "[data-alchemy-confirm-delete]", function (event) { + const $this = $(this) + const options = $this.data("alchemy-confirm-delete") + confirmToDeleteDialog($this.attr("href"), options) + event.preventDefault() + }) + $(scope).on("click", "[data-alchemy-confirm]", function (event) { + const options = $(this).data("alchemy-confirm") + openConfirmDialog( + options.message, + $.extend(options, { + ok_label: options.ok_label, + cancel_label: options.cancel_label, + on_ok: () => { + pleaseWaitOverlay() + this.form.submit() + } + }) + ) + event.preventDefault() + }) +} + export default function Initializer() { // We obviously have javascript enabled. $("html").removeClass("no-js") - // Initialize the GUI. - Alchemy.GUI.init() + // Initialize hotkeys. + Hotkeys() + + // Watch for click on confirm dialog links. + watchForConfirmDialogs() // Add observer for please wait overlay. $(".please_wait") From 881a5f95c1e0b9bea771e82d6d5d0d3d63af7cb8 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 24 Sep 2024 10:27:33 +0200 Subject: [PATCH 5/5] refactor(Dialog): Adhere code climate Try to adhere code climate issues --- .../alchemy_admin/components/dialog_link.js | 7 +- .../alchemy_admin/confirm_dialog.js | 20 ++-- app/javascript/alchemy_admin/dialog.js | 96 +++++++++++-------- app/javascript/alchemy_admin/image_overlay.js | 38 ++++---- .../alchemy/admin/pictures/index.html.erb | 5 +- .../components/dialog_link.spec.js | 4 +- 6 files changed, 93 insertions(+), 77 deletions(-) diff --git a/app/javascript/alchemy_admin/components/dialog_link.js b/app/javascript/alchemy_admin/components/dialog_link.js index 9504b0c0e2..792a201a6c 100644 --- a/app/javascript/alchemy_admin/components/dialog_link.js +++ b/app/javascript/alchemy_admin/components/dialog_link.js @@ -1,4 +1,4 @@ -import { DEFAULTS, Dialog } from "alchemy_admin/dialog" +import { Dialog } from "alchemy_admin/dialog" export class DialogLink extends HTMLAnchorElement { constructor() { @@ -22,10 +22,7 @@ export class DialogLink extends HTMLAnchorElement { const options = this.dataset.dialogOptions ? JSON.parse(this.dataset.dialogOptions) : {} - return { - ...DEFAULTS, - ...options - } + return options } get disabled() { diff --git a/app/javascript/alchemy_admin/confirm_dialog.js b/app/javascript/alchemy_admin/confirm_dialog.js index 80728b65ff..a542031348 100644 --- a/app/javascript/alchemy_admin/confirm_dialog.js +++ b/app/javascript/alchemy_admin/confirm_dialog.js @@ -3,20 +3,18 @@ import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay" import { createHtmlElement } from "alchemy_admin/utils/dom_helpers" import { translate } from "alchemy_admin/i18n" +const DEFAULTS = { + size: "300x100", + title: translate("Please confirm"), + ok_label: translate("Yes"), + cancel_label: translate("No"), + on_ok() {} +} + class ConfirmDialog { constructor(message, options = {}) { - const DEFAULTS = { - size: "300x100", - title: translate("Please confirm"), - ok_label: translate("Yes"), - cancel_label: translate("No"), - on_ok() {} - } - - options = { ...DEFAULTS, ...options } - this.message = message - this.options = options + this.options = { ...DEFAULTS, ...options } this.#build() this.#bindEvents() } diff --git a/app/javascript/alchemy_admin/dialog.js b/app/javascript/alchemy_admin/dialog.js index 74b083f33e..1d7fbd079d 100644 --- a/app/javascript/alchemy_admin/dialog.js +++ b/app/javascript/alchemy_admin/dialog.js @@ -4,7 +4,7 @@ import Spinner from "alchemy_admin/spinner" // Collection of all current dialog instances const currentDialogs = [] -export const DEFAULTS = { +const DEFAULTS = { header_height: 36, size: "400x300", padding: true, @@ -21,13 +21,9 @@ export class Dialog { // - options: A object holding options // - size: The maximum size of the Dialog // - title: The title of the Dialog - constructor(url, options) { + constructor(url, options = {}) { this.url = url - if (options == null) { - options = {} - } - this.options = options - this.options = $.extend({}, DEFAULTS, this.options) + this.options = { ...DEFAULTS, ...options } this.$document = $(document) this.$window = $(window) this.$body = $("body") @@ -35,6 +31,7 @@ export class Dialog { this.width = parseInt(size[0], 10) this.height = parseInt(size[1], 10) this.build() + this.resize() } // Opens the Dialog and loads the content via ajax. @@ -154,15 +151,32 @@ export class Dialog { } // Displays an error message - show_error(xhr, status_message, $container) { + show_error(xhr, statusText) { + if (xhr.status === 422) { + this.dialog_body.html(xhr.responseText) + this.init() + return + } + + const { error_body, error_header, error_type } = this.error_messages( + xhr, + statusText + ) + + const $errorDiv = $(` +

${error_header}

+

${error_body}

+
`) + + this.dialog_body.html($errorDiv) + } + + // Returns error message based on xhr status + error_messages(xhr, statusText) { let error_body, error_header, error_type = "warning" - if ($container == null) { - $container = this.dialog_body - } - switch (xhr.status) { case 0: error_header = "The server does not respond." @@ -172,14 +186,10 @@ export class Dialog { error_header = "You are not authorized!" error_body = "Please close this window." break - case 422: - this.dialog_body.html(xhr.responseText) - this.init() - return default: error_type = "error" - if (status_message) { - error_header = status_message + if (statusText) { + error_header = statusText console.error(xhr.responseText) } else { error_header = `${xhr.statusText} (${xhr.status})` @@ -187,12 +197,7 @@ export class Dialog { error_body = "Please check log and try again." } - const $errorDiv = $(` -

${error_header}

-

${error_body}

-
`) - - $container.html($errorDiv) + return { error_header, error_body, error_type } } // Binds close events on: @@ -247,41 +252,50 @@ export class Dialog { this.$body.append(this.overlay) } this.$body.append(this.dialog_container) - this.resize() } // Sets the correct size of the dialog // It normalizes the given size, so that it never acceeds the window size. resize() { - const padding = 16 - const $doc_width = this.$window.width() - const $doc_height = this.$window.height() - if (this.options.size === "fullscreen") { - ;[this.width, this.height] = Array.from([$doc_width, $doc_height]) - } - if (this.width >= $doc_width) { - this.width = $doc_width - padding - } - if (this.height >= $doc_height) { - this.height = $doc_height - padding - DEFAULTS.header_height - } + const { width, height } = this.getSize() + this.dialog.css({ - width: this.width, - "min-height": this.height, + width: width, + "min-height": height, overflow: this.options.overflow }) + if (this.options.overflow === "hidden") { this.dialog_body.css({ - height: this.height, + height: height, overflow: "auto" }) } else { this.dialog_body.css({ - "min-height": this.height, + "min-height": height, overflow: "visible" }) } } + + getSize() { + const padding = this.options.padding ? 16 : 0 + const doc_width = this.$window.width() + const doc_height = this.$window.height() + + let width = this.width + let height = this.height + + if (width >= doc_width) { + width = doc_width - padding + } + + if (height >= doc_height) { + height = doc_height - padding - DEFAULTS.header_height + } + + return { width, height } + } } // Gets the last dialog instantiated, which is the current one. diff --git a/app/javascript/alchemy_admin/image_overlay.js b/app/javascript/alchemy_admin/image_overlay.js index b78caab6e9..bcba70c0dd 100644 --- a/app/javascript/alchemy_admin/image_overlay.js +++ b/app/javascript/alchemy_admin/image_overlay.js @@ -2,8 +2,8 @@ import ImageLoader from "alchemy_admin/image_loader" import { Dialog } from "alchemy_admin/dialog" export default class ImageOverlay extends Dialog { - constructor(url) { - super(url) + constructor(url, options = {}) { + super(url, options) } init() { @@ -22,21 +22,7 @@ export default class ImageOverlay extends Dialog { }) this.$previous = $(".previous-picture") this.$next = $(".next-picture") - this.$document.keydown((e) => { - if (e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA") { - return true - } - switch (e.which) { - case 37: - this.previous() - return false - case 39: - this.next() - return false - default: - return true - } - }) + this.#initKeyboardNavigation() super.init() } @@ -66,4 +52,22 @@ export default class ImageOverlay extends Dialog { this.$body.append(this.overlay) this.$body.append(this.dialog_container) } + + #initKeyboardNavigation() { + this.$document.keydown((e) => { + if (e.target.nodeName === "INPUT" || e.target.nodeName === "TEXTAREA") { + return true + } + switch (e.which) { + case 37: + this.previous() + return false + case 39: + this.next() + return false + default: + return true + } + }) + } } diff --git a/app/views/alchemy/admin/pictures/index.html.erb b/app/views/alchemy/admin/pictures/index.html.erb index 8bd55a43e0..d7c6bc9645 100644 --- a/app/views/alchemy/admin/pictures/index.html.erb +++ b/app/views/alchemy/admin/pictures/index.html.erb @@ -94,7 +94,10 @@ pictureSelector(); on("click", "#picture_archive", ".thumbnail_background", (event) => { const url = event.target.closest("a")?.href; - const overlay = new ImageOverlay(url); + const overlay = new ImageOverlay(url, { + size: `${window.innerWidth}x${window.innerHeight}`, + padding: false + }); overlay.open(); event.preventDefault(); }); diff --git a/spec/javascript/alchemy_admin/components/dialog_link.spec.js b/spec/javascript/alchemy_admin/components/dialog_link.spec.js index ec3196e781..c9911bb19a 100644 --- a/spec/javascript/alchemy_admin/components/dialog_link.spec.js +++ b/spec/javascript/alchemy_admin/components/dialog_link.spec.js @@ -1,5 +1,5 @@ import "alchemy_admin/components/dialog_link" -import { Dialog, DEFAULTS } from "alchemy_admin/dialog" +import { Dialog } from "alchemy_admin/dialog" import { renderComponent } from "./component.helper" // import jquery and append it to the window object @@ -25,7 +25,7 @@ describe("alchemy-dialog-link", () => { ` const dialogLink = renderComponent("alchemy-dialog-link", html) - expect(dialogLink.dialogOptions).toEqual(DEFAULTS) + expect(dialogLink.dialogOptions).toEqual({}) }) it("parses dialogOptions from dataset", () => {