From 24be9b779e393366eb62d7f345b008a0fcd03bc7 Mon Sep 17 00:00:00 2001 From: Erik Huelsmann Date: Sun, 1 Dec 2024 22:58:50 +0100 Subject: [PATCH] Update UI only from last click This prevents any in-flight page loads updating the UI when a newer page has already been loaded. This can happen when loading one menu item takes long and the user proceeded to clicking another menu item. This also prevents unloading the page when it's in a state that isn't unloadable, because Dojo's parser is working on it. --- UI/src/components/ServerUI.js | 102 +++--------- UI/src/components/ServerUI.machines.js | 221 +++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 78 deletions(-) create mode 100644 UI/src/components/ServerUI.machines.js diff --git a/UI/src/components/ServerUI.js b/UI/src/components/ServerUI.js index a9b4c90b9d..2b8cb16075 100644 --- a/UI/src/components/ServerUI.js +++ b/UI/src/components/ServerUI.js @@ -3,8 +3,9 @@ import { h, inject, ref } from "vue"; import { useI18n } from "vue-i18n"; +import { createServerUIMachine } from "./ServerUI.machines.js"; + const registry = require("dijit/registry"); -const parser = require("dojo/parser"); const query = require("dojo/query"); const topic = require("dojo/topic"); @@ -33,82 +34,8 @@ export default { } }, methods: { - async updateContent(tgt, options = {}) { - let dismiss; - try { - this.notify({ - title: options.doing || this.$t("Loading..."), - type: "info", - dismissReceiver: (cb) => { - dismiss = cb; - } - }); - let headers = new Headers(options.headers); - headers.set("X-Requested-With", "XMLHttpRequest"); - - document - .getElementById("maindiv") - .removeAttribute("data-lsmb-done"); - // chop off the leading '/' to use relative paths - let base = window.location.pathname.replace(/[^/]*$/, ""); - let relTgt = - tgt.substring(0, 1) === "/" ? tgt.substring(1) : tgt; - let r = await fetch(base + relTgt, { - method: options.method, - body: options.data, - headers: headers - // additional parameters to consider: - // mode(cors?), credentials, referrerPolicy? - }); - - if (r.ok && !domReject(r)) { - let newContent = await r.text(); - this.notify({ - title: options.done || this.$t("Loaded") - }); - if (newContent === this.content) { - // when there is no difference in returned content, - // Vue won't re-render... so don't rerun the parser! - return; - } - this._cleanWidgets(); - this.content = newContent; - this.$nextTick(() => { - let maindiv = document.getElementById("maindiv"); - parser.parse(maindiv).then( - () => { - registry - .findWidgets(maindiv) - .forEach((child) => { - this.recursivelyResize(child); - }); - maindiv - .querySelectorAll("a") - .forEach((node) => - this._interceptClick(node) - ); - if (dismiss) { - dismiss(); - } - topic.publish("lsmb/page-fresh-content"); - maindiv.setAttribute("data-lsmb-done", "true"); - this._setFormFocus(); - }, - (e) => { - this.reportError(e); - } - ); - }); - } else { - this.reportError(r); - } - } catch (e) { - this.reportError(e); - } finally { - if (dismiss) { - dismiss(); - } - } + updateContent(tgt, options = {}) { + this.machine.send({ type: "loadContent", value: { tgt, options } }); }, _setFormFocus() { [...document.forms].forEach((form) => { @@ -188,7 +115,26 @@ export default { } }, beforeRouteLeave() { - this._cleanWidgets(); + this.machine.send("unloadContent"); + return ( + this.machine.current !== "parsing" && + this.machine.current !== "updating" + ); + }, + created() { + let maindiv = document.getElementById("maindiv"); + this.machine = createServerUIMachine( + { + notify: this.notify, + view: this + }, + ({ machine }) => { + if (machine.current === "idle") { + topic.publish("lsmb/page-fresh-content"); + maindiv.setAttribute("data-lsmb-done", "true"); + } + } + ); }, mounted() { document diff --git a/UI/src/components/ServerUI.machines.js b/UI/src/components/ServerUI.machines.js new file mode 100644 index 0000000000..ed3a996501 --- /dev/null +++ b/UI/src/components/ServerUI.machines.js @@ -0,0 +1,221 @@ +/** @format */ + +import { + action, + createMachine, + immediate, + interpret, + invoke, + guard, + reduce, + state, + transition +} from "@/robot-vue"; + +const registry = require("dijit/registry"); +const parser = require("dojo/parser"); + +function armWidgets(ctx) { + let maindiv = document.getElementById("maindiv"); + registry.findWidgets(maindiv).forEach((child) => { + ctx.view.recursivelyResize(child); + }); + maindiv + .querySelectorAll("a") + .forEach((node) => ctx.view._interceptClick(node)); + ctx.view._setFormFocus(); +} + +function disarmWidgets(ctx) { + ctx.view._cleanWidgets(); +} + +function dismissNotify(ctx) { + if (ctx.dismiss) { + ctx.dismiss(); + delete ctx.dismiss; + } +} + +function domAcceptable(ctx, { data }) { + let response = data.response; + let rv = !( + response.headers.get("X-LedgerSMB-App-Content") !== "yes" || + (response.headers.get("Content-Disposition") || "").startsWith( + "attachment" + ) + ); + return rv; +} + +async function requestContent(ctx) { + let headers = new Headers(ctx.options.headers); + headers.set("X-Requested-With", "XMLHttpRequest"); + + document.getElementById("maindiv").removeAttribute("data-lsmb-done"); + // chop off the leading '/' to use relative paths + let base = window.location.pathname.replace(/[^/]*$/, ""); + let tgt = ctx.tgt; + let relTgt = tgt.substring(0, 1) === "/" ? tgt.substring(1) : tgt; + return { + response: await fetch(base + relTgt, { + method: ctx.options.method, + body: ctx.options.data, + headers: headers + // additional parameters to consider: + // mode(cors?), credentials, referrerPolicy? + }) + }; +} + +async function retrieveContent(ctx) { + return await ctx.response.text(); +} + +async function updateContent(ctx) { + ctx.view.content = ctx.content; + let p = new Promise((resolve) => { + ctx.view.$nextTick(() => { + resolve(); + }); + }); + await p; +} + +async function parseContent(ctx) { + let maindiv = document.getElementById("maindiv"); + let p = new Promise((resolve) => { + parser.parse(maindiv).then(() => { + resolve(); + }); + }); + await p; + ctx.view.notify({ + title: ctx.options.done || ctx.view.$t("Loaded") + }); +} + +function reportError(ctx) { + ctx.view.reportError(ctx.error); +} + +function setContentSrc(ctx, { value }) { + let dismiss; + let dismissed = false; + ctx.notify({ + title: value.options.doing || ctx.view.$t("Loading..."), + type: "info", + dismissReceiver: (cb) => { + if (dismissed) { + // receiving the callback *after* someone tried to dismiss... + // do it right now. + cb(); + } else { + dismiss = cb; + } + } + }); + return { + ...ctx, + tgt: value.tgt, + options: value.options, + // 'dismiss' is received delayed; pass a forwarder + dismiss: () => { + dismissed = true; + if (dismiss) { + dismiss(); + } + } + }; +} + +function setContent(ctx, { data }) { + return { ...ctx, content: data }; +} + +function setError(ctx, { data }) { + return { ...ctx, error: data }; +} + +function setErrorResponse(ctx, { data }) { + return { ...ctx, error: data.response }; +} + +function setResponse(ctx, { data }) { + return { ...ctx, response: data.response }; +} + +const machine = createMachine( + { + idle: state( + transition("loadContent", "requesting", reduce(setContentSrc)), + transition("unloadContent", "unloaded", action(disarmWidgets)) + ), + requesting: invoke( + requestContent, + transition( + "loadContent", + "requesting", + action(dismissNotify), + reduce(setContentSrc) + ), + transition( + "unloadContent", + "unloaded", + action(dismissNotify), + action(disarmWidgets) + ), + transition( + "done", + "retrieving", + guard(domAcceptable), + reduce(setResponse) + ), + transition("done", "error", reduce(setErrorResponse)), + transition("error", "error", reduce(setError)) + ), + retrieving: invoke( + retrieveContent, + transition( + "loadContent", + "requesting", + action(dismissNotify), + reduce(setContentSrc) + ), + transition( + "unloadContent", + "unloaded", + action(dismissNotify), + action(disarmWidgets) + ), + transition("done", "disarming", reduce(setContent)), + transition("error", "error", reduce(setError)) + ), + disarming: state(immediate("updating", action(disarmWidgets))), + updating: invoke( + updateContent, + transition("done", "parsing"), + transition("error", "error", reduce(setError)) + ), + parsing: invoke( + parseContent, + transition("done", "arming"), + transition("error", "error", reduce(setError)) + ), + arming: state( + immediate("idle", action(armWidgets), action(dismissNotify)) + ), + error: invoke( + reportError, + transition("done", "idle", action(dismissNotify)) + ), + unloaded: state() + }, + (ctx) => ({ ...ctx }) +); + +function createServerUIMachine(initialContext, callback) { + return interpret(machine, callback, initialContext); +} + +export { createServerUIMachine };