Skip to content

Commit

Permalink
Add alchemy-button web component
Browse files Browse the repository at this point in the history
This allows to remove all the Alchemy.Buttons state management
code we have right now and let the browser do it for us.

This DOES not work in Safari as they do not support extending
existing HTML elements, but this is ok in this case. Maybe we will
provide a polyfill someday or just do not support this feature in Safari.

In order to update forms not using the `alchemy_form_form` helper,
replace all usages of `data-alchemy-button` with `is="alchemy-button"`.
  • Loading branch information
tvdeyen committed Nov 24, 2023
1 parent 8d23973 commit c682ff0
Show file tree
Hide file tree
Showing 16 changed files with 133 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 0 additions & 3 deletions app/assets/javascripts/alchemy/alchemy.dialog.js.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions app/javascript/alchemy_admin.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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
Expand Down
62 changes: 0 additions & 62 deletions app/javascript/alchemy_admin/buttons.js

This file was deleted.

52 changes: 52 additions & 0 deletions app/javascript/alchemy_admin/components/button.js
Original file line number Diff line number Diff line change
@@ -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" })
1 change: 0 additions & 1 deletion app/javascript/alchemy_admin/gui.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import TagsAutocomplete from "alchemy_admin/tags_autocomplete"

function init(scope) {
Alchemy.Buttons.observe(scope)
if (!scope) {
Alchemy.watchForDialogs()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<%= 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)],
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 %>
<button class="button add-nestable-element-button" data-alchemy-button>
<button class="add-nestable-element-button" is="alchemy-button">
<%= Alchemy.t(:add_nested_element, name: Alchemy.t(nestable_element, scope: 'element_names')) %>
</button>
<% end %>
Expand All @@ -24,4 +24,4 @@
}, class: "button add-nestable-element-button" %>
<% end %>
<% end %>
<% end %>
<% end %>
2 changes: 1 addition & 1 deletion app/views/alchemy/admin/elements/_footer.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</p>
<% end %>

<button type="submit" form="element_<%= element.id %>_form" class="button" data-alchemy-button>
<button type="submit" form="element_<%= element.id %>_form" is="alchemy-button">
<%= Alchemy.t(:save) %>
</button>
</div>
1 change: 0 additions & 1 deletion app/views/alchemy/admin/elements/create.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
1 change: 0 additions & 1 deletion app/views/alchemy/admin/elements/update.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
$errors.html('<%= j @error_message %><ul><li><%== j @error_messages.join("</li><li>") %></li></ul>');
$errors.show();
$('<%== @element.ingredients_with_errors.map { |ingredient| "[data-ingredient-id=\"#{ingredient.id}\"]" }.join(", ") %>').addClass('validation_failed');
Alchemy.Buttons.enable($el);

<%- end -%>
})();
2 changes: 1 addition & 1 deletion app/views/alchemy/admin/styleguide/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@

<div class="submit">
<button class="secondary">Secondary</button>
<button data-alchemy-button>Primary Submit</button>
<button type="submit" is="alchemy-button">Primary Submit</button>
</div>
</form>
</div>
Expand Down
1 change: 0 additions & 1 deletion app/views/alchemy/base/error_notice.js.erb
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
Alchemy.growl('<%= j @notice %>', 'error');
Alchemy.Buttons.enable();
5 changes: 3 additions & 2 deletions lib/alchemy/forms/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions spec/javascript/alchemy_admin/components/button.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `
<form>
<button type="submit" is="alchemy-button">Save</button>
</form>
`
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(
'&nbsp;<alchemy-spinner size="small" color="currentColor"></alchemy-spinner>'
)
})

it("logs warning if no form found", () => {
global.console = {
...console,
warn: jest.fn()
}

const html = `
<button is="alchemy-button">Save</button>
`
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 = `
<form data-remote="true">
<button type="submit" is="alchemy-button">Save</button>
</form>
`
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")
})
})
})
8 changes: 7 additions & 1 deletion spec/javascript/alchemy_admin/components/component.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
*/
export const renderComponent = (name, html) => {
document.body.innerHTML = html
return document.querySelector(name)
const component =
document.querySelector(name) || document.querySelector(`[is="${name}"]`)
if (component) {
return component
} else {
throw new Error(`Component '${name}' not found in '${html}'`)
}
}

export const setupLanguage = () => {
Expand Down

0 comments on commit c682ff0

Please sign in to comment.