diff --git a/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee index f8e3834689..4cc930e2ca 100644 --- a/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.confirm_dialog.js.coffee @@ -31,7 +31,6 @@ class window.Alchemy.ConfirmDialog extends Alchemy.Dialog @cancel_button.focus() @cancel_button.click => @close() - Alchemy.Buttons.enable() false @ok_button.click => @close() diff --git a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee index 1449e25ec5..cac43df8a7 100644 --- a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee @@ -109,9 +109,6 @@ class window.Alchemy.Dialog # Watches ajax requests inside of dialog body and replaces the content accordingly watch_remote_forms: -> form = $('[data-remote="true"]', @dialog_body) - form.bind "ajax:complete", () => - Alchemy.Buttons.enable(@dialog_body) - return form.bind "ajax:success", (event) => xhr = event.detail[2] content_type = xhr.getResponseHeader('Content-Type') diff --git a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee b/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee index b8874d8c8f..0f42efa547 100644 --- a/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.element_editors.js.coffee @@ -175,7 +175,6 @@ Alchemy.ElementEditors = # Prevent this event from beeing called twice on the same element if event.currentTarget == event.target Alchemy.setElementClean($element) - Alchemy.Buttons.enable($element) true # Toggle visibility of the ingredient fields in the group diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index 0483dfc517..af6c13e5b3 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -1,7 +1,6 @@ import "@hotwired/turbo-rails" import Rails from "@rails/ujs" -import Buttons from "alchemy_admin/buttons" import GUI from "alchemy_admin/gui" import { translate } from "alchemy_admin/i18n" import Dirty from "alchemy_admin/dirty" @@ -21,6 +20,7 @@ import PagePublicationFields from "alchemy_admin/page_publication_fields" $.fx.speeds._default = 400 // Web Components +import "alchemy_admin/components/button" import "alchemy_admin/components/char_counter" import "alchemy_admin/components/datepicker" import "alchemy_admin/components/node_select" @@ -41,7 +41,6 @@ if (typeof window.Alchemy === "undefined") { // Enhance the global Alchemy object with imported features Object.assign(Alchemy, { - Buttons, ...Dirty, GUI, t: translate, // Global utility method for translating a given string diff --git a/app/javascript/alchemy_admin/buttons.js b/app/javascript/alchemy_admin/buttons.js deleted file mode 100644 index c4bd574810..0000000000 --- a/app/javascript/alchemy_admin/buttons.js +++ /dev/null @@ -1,62 +0,0 @@ -function observe(scope) { - $("form", scope) - .not(".button_with_label form") - .on("submit", function (event) { - const $form = $(this) - const $btn = $form.find(":submit") - const $outside_button = $( - `[data-alchemy-button][form="${$form.attr("id")}"]` - ) - - const isDisabled = - $btn.attr("disabled") === "disabled" || - $outside_button.attr("disabled") === "disabled" - - if (isDisabled) { - event.preventDefault() - event.stopPropagation() - } else { - disable($btn) - if ($outside_button) { - disable($outside_button) - } - } - }) -} - -function disable(button) { - const $button = $(button) - const spinner = new Alchemy.Spinner("small") - $button.data("content", $button.html()) - $button.attr("disabled", true) - $button.attr("tabindex", "-1") - $button.addClass("disabled") - $button.css({ - width: $button.outerWidth(), - height: $button.outerHeight() - }) - $button.empty() - spinner.spin($button) -} - -function enable(scope) { - const $buttons = $( - "form :submit:disabled, [data-alchemy-button].disabled", - scope - ) - $.each($buttons, function () { - const $button = $(this) - $button.removeClass("disabled") - $button.removeAttr("disabled") - $button.removeAttr("tabindex") - $button.css("width", "") - $button.css("height", "") - $button.html($button.data("content")) - }) -} - -export default { - observe, - disable, - enable -} diff --git a/app/javascript/alchemy_admin/components/button.js b/app/javascript/alchemy_admin/components/button.js new file mode 100644 index 0000000000..8a2c1db3a7 --- /dev/null +++ b/app/javascript/alchemy_admin/components/button.js @@ -0,0 +1,52 @@ +import Spinner from "../spinner" + +class Button extends HTMLButtonElement { + connectedCallback() { + if (this.form) { + this.form.addEventListener("submit", (event) => { + const isDisabled = this.getAttribute("disabled") === "disabled" + + if (isDisabled) { + event.preventDefault() + event.stopPropagation() + } 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) + } + } + + disable() { + const spinner = new Spinner("small") + const rect = this.getBoundingClientRect() + + this.dataset.initialButtonText = this.innerHTML + this.setAttribute("disabled", "disabled") + this.setAttribute("tabindex", "-1") + this.classList.add("disabled") + this.style.width = `${rect.width}px` + this.style.height = `${rect.height}px` + this.innerHTML = " " + + spinner.spin(this) + } + + enable() { + this.classList.remove("disabled") + this.removeAttribute("disabled") + this.removeAttribute("tabindex") + this.style.width = null + this.style.height = null + this.innerHTML = this.dataset.initialButtonText + } +} + +customElements.define("alchemy-button", Button, { extends: "button" }) diff --git a/app/javascript/alchemy_admin/gui.js b/app/javascript/alchemy_admin/gui.js index 64c2504f9b..fcc0b2991a 100644 --- a/app/javascript/alchemy_admin/gui.js +++ b/app/javascript/alchemy_admin/gui.js @@ -1,7 +1,6 @@ import TagsAutocomplete from "alchemy_admin/tags_autocomplete" function init(scope) { - Alchemy.Buttons.observe(scope) if (!scope) { Alchemy.watchForDialogs() } 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 6f49ce4b79..7e53ab3134 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,7 +1,7 @@ <%= 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) && + <% 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)], @@ -9,7 +9,7 @@ <%= 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 %> @@ -24,4 +24,4 @@ }, class: "button add-nestable-element-button" %> <% end %> <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/alchemy/admin/elements/_footer.html.erb b/app/views/alchemy/admin/elements/_footer.html.erb index 68a6f685c9..43e9c03608 100644 --- a/app/views/alchemy/admin/elements/_footer.html.erb +++ b/app/views/alchemy/admin/elements/_footer.html.erb @@ -5,7 +5,7 @@

<% end %> - diff --git a/app/views/alchemy/admin/elements/create.js.erb b/app/views/alchemy/admin/elements/create.js.erb index 1a7da5ca56..1f89e986c3 100644 --- a/app/views/alchemy/admin/elements/create.js.erb +++ b/app/views/alchemy/admin/elements/create.js.erb @@ -15,7 +15,6 @@ $element_area = $('[name="fixed-element-<%= @element.id %>"]'); <% elsif @element.parent_element %> $element_area = $('#element_<%= @element.parent_element_id %> > .nestable-elements > .nested-elements'); - Alchemy.Buttons.enable('.nestable-elements'); <% else %> $element_area = $('#main-content-elements'); <% end %> diff --git a/app/views/alchemy/admin/elements/update.js.erb b/app/views/alchemy/admin/elements/update.js.erb index 7c4c4b670b..7cc9264ca6 100644 --- a/app/views/alchemy/admin/elements/update.js.erb +++ b/app/views/alchemy/admin/elements/update.js.erb @@ -21,7 +21,6 @@ $errors.html('<%= j @error_message %>'); $errors.show(); $('<%== @element.ingredients_with_errors.map { |ingredient| "[data-ingredient-id=\"#{ingredient.id}\"]" }.join(", ") %>').addClass('validation_failed'); - Alchemy.Buttons.enable($el); <%- end -%> })(); diff --git a/app/views/alchemy/admin/styleguide/index.html.erb b/app/views/alchemy/admin/styleguide/index.html.erb index 1257a1b0ad..1d79399405 100644 --- a/app/views/alchemy/admin/styleguide/index.html.erb +++ b/app/views/alchemy/admin/styleguide/index.html.erb @@ -152,7 +152,7 @@
- +
diff --git a/app/views/alchemy/base/error_notice.js.erb b/app/views/alchemy/base/error_notice.js.erb index 880112ec6b..3c630b6d39 100644 --- a/app/views/alchemy/base/error_notice.js.erb +++ b/app/views/alchemy/base/error_notice.js.erb @@ -1,2 +1 @@ Alchemy.growl('<%= j @notice %>', 'error'); -Alchemy.Buttons.enable(); diff --git a/lib/alchemy/forms/builder.rb b/lib/alchemy/forms/builder.rb index 59f08df068..df1a769fa8 100644 --- a/lib/alchemy/forms/builder.rb +++ b/lib/alchemy/forms/builder.rb @@ -41,10 +41,11 @@ def datepicker(attribute_name, options = {}) # def submit(label, options = {}) options = { - wrapper_html: {class: "submit"} + wrapper_html: {class: "submit"}, + input_html: {is: "alchemy-button"} }.update(options) template.content_tag("div", options.delete(:wrapper_html)) do - template.content_tag("button", label, options.delete(:input_html)) + template.button_tag(label, options.delete(:input_html)) end end end diff --git a/spec/javascript/alchemy_admin/components/button.spec.js b/spec/javascript/alchemy_admin/components/button.spec.js new file mode 100644 index 0000000000..32d144fad6 --- /dev/null +++ b/spec/javascript/alchemy_admin/components/button.spec.js @@ -0,0 +1,64 @@ +import "alchemy_admin/components/button" +import { renderComponent } from "./component.helper" + +describe("alchemy-button", () => { + it("disables button on form submit", () => { + const html = ` +
+ +
+ ` + const button = renderComponent("alchemy-button", html) + const submit = new Event("submit", { bubbles: true }) + + button.form.dispatchEvent(submit) + + expect(button.getAttribute("disabled")).toEqual("disabled") + expect(button.getAttribute("tabindex")).toEqual("-1") + expect(button.classList.contains("disabled")).toBeTruthy() + expect(button.innerHTML).toEqual( + ' ' + ) + }) + + it("logs warning if no form found", () => { + global.console = { + ...console, + warn: jest.fn() + } + + const html = ` + + ` + const button = renderComponent("alchemy-button", html) + + expect(console.warn).toHaveBeenCalledWith( + "No form for button found!", + button + ) + }) + + describe("on remote forms", () => { + it("re-enables button on ajax complete", () => { + const html = ` +
+ +
+ ` + const button = renderComponent("alchemy-button", html) + + const submit = new Event("submit", { bubbles: true }) + button.form.dispatchEvent(submit) + + expect(button.getAttribute("disabled")).toEqual("disabled") + + const ajaxComplete = new CustomEvent("ajax:complete", { bubbles: true }) + button.form.dispatchEvent(ajaxComplete) + + expect(button.getAttribute("disabled")).toBeNull() + expect(button.getAttribute("tabindex")).toBeNull() + expect(button.classList.contains("disabled")).toBeFalsy() + expect(button.innerHTML).toEqual("Save") + }) + }) +}) diff --git a/spec/javascript/alchemy_admin/components/component.helper.js b/spec/javascript/alchemy_admin/components/component.helper.js index 9f0b68bff2..a919d763ef 100644 --- a/spec/javascript/alchemy_admin/components/component.helper.js +++ b/spec/javascript/alchemy_admin/components/component.helper.js @@ -6,7 +6,7 @@ */ export const renderComponent = (name, html) => { document.body.innerHTML = html - return document.querySelector(name) + return document.querySelector(`${name}, [is="${name}"]`) } export const setupLanguage = () => { diff --git a/spec/javascript/alchemy_admin/utils/ajax.spec.js b/spec/javascript/alchemy_admin/utils/ajax.spec.js index a42956474f..eff8790f9b 100644 --- a/spec/javascript/alchemy_admin/utils/ajax.spec.js +++ b/spec/javascript/alchemy_admin/utils/ajax.spec.js @@ -55,6 +55,10 @@ describe("get", () => { }) it("network errors get rejected", async () => { + global.console = { + ...console, + error: jest.fn() + } xhrMock.get("http://localhost/users", () => { return Promise.reject(new Error()) })