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")
+ })
+})