From 98eb7aa5e6cbe1b730da5080bda53a9e1a5df259 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Tue, 7 May 2024 19:55:06 +0200 Subject: [PATCH] WIP: Remote Partial Component Try to extract the logic to render the content of the dialog and the error handing into a separate component. --- .../alchemy_admin/components/index.js | 1 + .../components/remote_partial.js | 91 +++++++++++++++++++ app/javascript/alchemy_admin/dialog.js | 51 +++-------- app/javascript/alchemy_admin/locales/en.js | 3 + .../admin/page_editing_feature_spec.rb | 2 +- .../components/remote_partial.spec.js | 46 ++++++++++ 6 files changed, 157 insertions(+), 37 deletions(-) create mode 100644 app/javascript/alchemy_admin/components/remote_partial.js create mode 100644 spec/javascript/alchemy_admin/components/remote_partial.spec.js diff --git a/app/javascript/alchemy_admin/components/index.js b/app/javascript/alchemy_admin/components/index.js index 384da76f82..6f0bc020d2 100644 --- a/app/javascript/alchemy_admin/components/index.js +++ b/app/javascript/alchemy_admin/components/index.js @@ -19,6 +19,7 @@ import "alchemy_admin/components/uploader" import "alchemy_admin/components/overlay" import "alchemy_admin/components/page_select" import "alchemy_admin/components/preview_window" +import "alchemy_admin/components/remote_partial" import "alchemy_admin/components/select" import "alchemy_admin/components/spinner" import "alchemy_admin/components/tags_autocomplete" diff --git a/app/javascript/alchemy_admin/components/remote_partial.js b/app/javascript/alchemy_admin/components/remote_partial.js new file mode 100644 index 0000000000..fd16ca631c --- /dev/null +++ b/app/javascript/alchemy_admin/components/remote_partial.js @@ -0,0 +1,91 @@ +import { translate } from "alchemy_admin/i18n" + +/** + * the remote partial will automatically load the content of the given url and + * put the fetched content into the inner component. It also handles different + * kinds of error cases. + */ +class RemotePartial extends HTMLElement { + constructor() { + super() + this.addEventListener("ajax:success", this) + this.addEventListener("ajax:error", this) + } + + /** + * handle the ajax event from inner forms + * this is an intermediate solution until we moved away from jQuery + * @param {CustomEvent} event + * @deprecated + */ + handleEvent(event) { + const status = event.detail[1] + /** @type {XMLHttpRequest} xhr */ + const xhr = event.detail[2] + + switch (event.type) { + case "ajax:success": + const isTextResponse = xhr + .getResponseHeader("Content-Type") + .match(/html/) + + if (isTextResponse) { + this.innerHTML = xhr.responseText + } + break + case "ajax:error": + this.#showErrorMessage(status, xhr.responseText, "error") + break + } + } + + /** + * show the spinner and load the content + * after the content is loaded the spinner will be replaced by the fetched content + */ + connectedCallback() { + this.innerHTML = `` + + fetch(this.url, { + redirect: "error", + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + .then(async (response) => { + if (response.ok) { + this.innerHTML = await response.text() + } else { + this.#showErrorMessage( + response.statusText, + await response.text(), + "error" + ) + } + }) + .catch(() => { + this.#showErrorMessage( + translate("The server does not respond."), + translate("Please check server and try again.") + ) + }) + } + + /** + * @param {string} title + * @param {string} description + * @param {"warning"|"error"} type + */ + #showErrorMessage(title, description, type = "warning") { + this.innerHTML = ` + +

${title}

+

${description}

+
+ ` + } + + get url() { + return this.getAttribute("url") + } +} + +customElements.define("alchemy-remote-partial", RemotePartial) diff --git a/app/javascript/alchemy_admin/dialog.js b/app/javascript/alchemy_admin/dialog.js index e2d5922e2a..eb567875ef 100644 --- a/app/javascript/alchemy_admin/dialog.js +++ b/app/javascript/alchemy_admin/dialog.js @@ -21,28 +21,20 @@ export class Dialog { */ open() { this.#build() - // Show the dialog with the spinner after a small delay. - // in most cases the content of the dialog is already available and the spinner is not flashing - setTimeout(() => this.#openDialog, 300) + this.#openDialog() + this.#select2Handling() - this.#loadContent().then((content) => { - // create the dialog markup and show the dialog - this.#dialogComponent.innerHTML = content - this.#select2Handling() - this.#openDialog() + // bind the current class instance to the DOM - element + // this should be an intermediate solution + // the main goal, is to close the dialog with the turbo:submit-end - event + this.#dialogComponent.dialogClassInstance = this - // bind the current class instance to the DOM - element - // this should be an intermediate solution - // the main goal, is to close the dialog with the turbo:submit-end - event - this.#dialogComponent.dialogClassInstance = this - - // the dialog is closing with the overlay, esc - key, or close - button - // the reject - callback will be fired, because the user decided to close the - // dialog without saving anything - this.#dialogComponent.addEventListener("sl-request-close", () => { - this.#removeDialog() - this.#onReject() - }) + // the dialog is closing with the overlay, esc - key, or close - button + // the reject - callback will be fired, because the user decided to close the + // dialog without saving anything + this.#dialogComponent.addEventListener("sl-after-hide", () => { + this.#removeDialog() + this.#onReject() }) return new Promise((resolve, reject) => { @@ -69,24 +61,13 @@ export class Dialog { }) } - /** - * load content of the given url - * @returns {Promise} - */ - async #loadContent() { - const response = await fetch(this.url, { - headers: { "X-Requested-With": "XMLHttpRequest" } - }) - return await response.text() - } - /** * create and append the dialog container to the DOM */ #build() { this.#dialogComponent = createHtmlElement(` - + `) document.body.append(this.#dialogComponent) @@ -107,10 +88,8 @@ export class Dialog { * remove the dialog from dom */ #removeDialog() { - this.#dialogComponent.addEventListener("sl-after-hide", () => { - this.#dialogComponent.remove() - this.#isOpen = false - }) + this.#dialogComponent.remove() + this.#isOpen = false } /** diff --git a/app/javascript/alchemy_admin/locales/en.js b/app/javascript/alchemy_admin/locales/en.js index 9a61436cca..75a65223f2 100644 --- a/app/javascript/alchemy_admin/locales/en.js +++ b/app/javascript/alchemy_admin/locales/en.js @@ -25,6 +25,9 @@ export const en = { "No anchors found": "No anchors found", "Select a page first": "Select a page first", Close: "Close", + "The server does not respond.": "The server does not respond.", + "Please check server and try again.": "Please check server and try again.", + "Please check log and try again.": "Please check log and try again.", formats: { datetime: "Y-m-d H:i", date: "Y-m-d", diff --git a/spec/features/admin/page_editing_feature_spec.rb b/spec/features/admin/page_editing_feature_spec.rb index a431f6e748..3bee15af5f 100644 --- a/spec/features/admin/page_editing_feature_spec.rb +++ b/spec/features/admin/page_editing_feature_spec.rb @@ -239,7 +239,7 @@ let!(:new_parent) { create(:alchemy_page) } it "can change page parent" do - within(".simple_form:first-child") do + within(".simple_form.edit_page") do expect(page).to have_css("#s2id_page_parent_id") select2_search(new_parent.name, from: "Parent") find(".edit_page .submit button").click diff --git a/spec/javascript/alchemy_admin/components/remote_partial.spec.js b/spec/javascript/alchemy_admin/components/remote_partial.spec.js new file mode 100644 index 0000000000..5ec990e846 --- /dev/null +++ b/spec/javascript/alchemy_admin/components/remote_partial.spec.js @@ -0,0 +1,46 @@ +import "alchemy_admin/components/remote_partial" + +describe("alchemy-remote-partial", () => { + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve("Foo") + }) + ) + + /** + * @type {RemotePartial | undefined} + */ + let partial = undefined + + const renderComponent = (url = null) => { + document.body.innerHTML = `` + partial = document.querySelector("alchemy-remote-partial") + return new Promise((resolve) => { + setTimeout(() => resolve()) + }) + } + + it("should render a spinner as initial content", () => { + renderComponent() + expect(document.querySelector("alchemy-spinner")).toBeTruthy() + }) + + it("should fetch the given url", () => { + renderComponent("http://foo.bar") + expect(fetch).toHaveBeenCalledWith("http://foo.bar", { + headers: { "X-Requested-With": "XMLHttpRequest" } + }) + }) + + it("should replace the spinner with the fetched content", async () => { + await renderComponent() + expect(partial.innerHTML).toBe("Foo") + }) + + it("should render an error message", async () => { + fetch.mockImplementationOnce(() => Promise.reject("API is down")) + + await renderComponent() + expect(partial.innerHTML).toBe("1231231223112312123132312") + }) +})