diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6242bb00..63ac360f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,7 @@ Lines marked **Upgrade step** need you to act after updating.
- Questions, answers and template imports now also refuse these values with a message that names the field, and nothing is changed: a question text over 65,535 bytes (a letter like "ä" counts as 2), a webhook address or answer name over 255 characters, a required, hidden or editable value that isn't yes or no, a question or answer position in a template that isn't a whole number, is below zero or is too big, and a template whose answers or logic rules aren't a list. Before, most of these failed with a server error on MySQL and MariaDB, or were saved anyway. A form setting in a template that is only spaces now counts as empty, like on the settings pages, instead of failing. (#261)
- While the form editor loads your questions, it now shows grey placeholder rows instead of the "Welcome to the Editor" text. When the questions can't load, it says so and offers Try again. Before, a spinner turned forever. (#263)
- A logic rule that uses or goes to a deleted question, or one of another form, is now refused with a message that names the field. Before, it saved and looked like it worked. The blocks API now lists, for each question, the rules that use it (`used_by`). (#264)
+- The form editor now shows "Saving…", "Saved" or "Not saved" with Retry. A failed save names the question by number and text, and the red bar offers Retry. Reloading or closing the page right after typing keeps the change, and the browser asks before you leave while a change isn't saved. Before, the editor showed nothing, and a reload within a second lost the change. (#267)
### Security
diff --git a/resources/js/Jetstream/Banner.test.ts b/resources/js/Jetstream/Banner.test.ts
index a0bbee5e..6d316af5 100644
--- a/resources/js/Jetstream/Banner.test.ts
+++ b/resources/js/Jetstream/Banner.test.ts
@@ -1,30 +1,91 @@
-import { mount } from "@vue/test-utils";
-import { describe, expect, it } from "vitest";
+import { mount, VueWrapper } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import Banner from "./Banner.vue";
import { useNotice } from "@/stores/notice";
-const mountBanner = () =>
+const retry = (wrapper: VueWrapper) =>
+ wrapper.findAll("[role=alert] button").find((b) => b.text() === "Retry");
+const mountBanner = (flash = {}) =>
mount(Banner, {
- global: { mocks: { $page: { props: { jetstream: { flash: {} } } } } },
+ global: { mocks: { $page: { props: { jetstream: { flash } } } } },
});
describe("banner", () => {
- it("shows a save error until it is dismissed", async () => {
+ beforeEach(() => useNotice().$reset());
+
+ it("hides a save error when dismissed, but keeps the failed save", async () => {
const wrapper = mountBanner();
// the test pinia stubs actions, so set the state directly
useNotice().$patch({
- failedSaves: { "saveBlock:1": "Your change was not saved." },
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ retries: { "saveBlock:1": vi.fn() },
});
await wrapper.vm.$nextTick();
expect(wrapper.get("[role=alert]").text()).toContain(
- "Your change was not saved.",
+ "Your change wasn't saved.",
);
await wrapper.get("button[aria-label=Dismiss]").trigger("click");
- expect(useNotice().message).toBeNull();
expect(wrapper.find("[role=alert]").exists()).toBe(false);
+ // "Not saved" with Retry and the leave prompt still need it
+ expect(useNotice().saveStatus).toBe("failed");
+ expect(useNotice().canRetry).toBe(true);
+ });
+
+ // below lg the bar is the only sign of a failed save
+ it("keeps the close button off phones while a save failed", async () => {
+ const wrapper = mountBanner();
+
+ useNotice().$patch({
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ retries: { "saveBlock:1": vi.fn() },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.get("button[aria-label=Dismiss]").classes()).toContain(
+ "max-lg:hidden",
+ );
+ });
+
+ // e.g. settings pages: without Retry the bar would never go away
+ it("lets phones close a failed save that has no Retry", async () => {
+ const wrapper = mountBanner();
+
+ useNotice().$patch({
+ failedSaves: { saveSettings: "Your settings weren't saved." },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(
+ wrapper.get("button[aria-label=Dismiss]").classes(),
+ ).not.toContain("max-lg:hidden");
+ });
+
+ it("lets phones close any other banner", () => {
+ const wrapper = mountBanner({ banner: "Saved." });
+
+ expect(
+ wrapper.get("button[aria-label=Dismiss]").classes(),
+ ).not.toContain("max-lg:hidden");
+ });
+
+ it("offers Retry for a save that can be sent again", async () => {
+ const wrapper = mountBanner();
+
+ useNotice().$patch({
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ });
+ await wrapper.vm.$nextTick();
+ expect(retry(wrapper)).toBeUndefined();
+
+ useNotice().$patch({ retries: { "saveBlock:1": vi.fn() } });
+ await wrapper.vm.$nextTick();
+ await retry(wrapper)!.trigger("click");
+
+ expect(useNotice().retrySaves).toHaveBeenCalled();
+ wrapper.unmount();
});
});
diff --git a/resources/js/Jetstream/Banner.vue b/resources/js/Jetstream/Banner.vue
index b4e6b569..a015f2c3 100644
--- a/resources/js/Jetstream/Banner.vue
+++ b/resources/js/Jetstream/Banner.vue
@@ -12,7 +12,7 @@
-
+
+ Retry
+
+
+
{
status.value = "loaded";
};
+useSaveOnLeave();
+
onBeforeUnmount(() => {
store.clearForm();
});
diff --git a/resources/js/api/blocks.ts b/resources/js/api/blocks.ts
index d81c65d8..10b359c0 100644
--- a/resources/js/api/blocks.ts
+++ b/resources/js/api/blocks.ts
@@ -1,5 +1,5 @@
/* eslint-disable no-async-promise-executor */
-import { AxiosResponse } from "axios";
+import { AxiosRequestConfig, AxiosResponse } from "axios";
import handler from "./handler";
export function callGetFormBlocks(
@@ -45,13 +45,15 @@ export function callCreateFormBlock(
}
export function callUpdateFormBlock(
- block: FormBlockModel
+ block: FormBlockModel,
+ config?: AxiosRequestConfig
): Promise> {
return new Promise(async (resolve, reject) => {
try {
const response = await handler.post(
window.route("api.blocks.update", { block: block.id }),
- block
+ block,
+ config
);
resolve(response as AxiosResponse);
diff --git a/resources/js/api/handler.ts b/resources/js/api/handler.ts
index 93bb594e..682ced6a 100644
--- a/resources/js/api/handler.ts
+++ b/resources/js/api/handler.ts
@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { AxiosRequestConfig } from "axios";
import csrfToken from "./interceptors/csrfToken";
const handler = axios.create({
@@ -11,4 +11,11 @@ const handler = axios.create({
handler.interceptors.request.use(csrfToken);
+// A save sent while the page closes: fetch with keepalive outlives the page,
+// an XHR can be dropped (mobile Safari). Keepalive allows at most 64 KB.
+export const keepAlive = (data: object): AxiosRequestConfig | undefined =>
+ new TextEncoder().encode(JSON.stringify(data)).length < 60_000
+ ? { adapter: "fetch", fetchOptions: { keepalive: true } }
+ : undefined;
+
export default handler;
diff --git a/resources/js/api/interactions.ts b/resources/js/api/interactions.ts
index 957d8ccc..5144c447 100644
--- a/resources/js/api/interactions.ts
+++ b/resources/js/api/interactions.ts
@@ -1,5 +1,5 @@
/* eslint-disable no-async-promise-executor */
-import { AxiosResponse } from "axios";
+import { AxiosRequestConfig, AxiosResponse } from "axios";
import handler from "./handler";
export function callCreateFormBlockInteraction(
@@ -25,7 +25,8 @@ export function callCreateFormBlockInteraction(
}
export function callUpdateFormBlockInteraction(
- interaction: FormBlockInteractionModel
+ interaction: FormBlockInteractionModel,
+ config?: AxiosRequestConfig
): Promise> {
return new Promise(async (resolve, reject) => {
try {
@@ -33,7 +34,8 @@ export function callUpdateFormBlockInteraction(
window.route("api.interactions.update", {
interaction: interaction.id,
}),
- interaction
+ interaction,
+ config
);
resolve(response as AxiosResponse);
diff --git a/resources/js/components/Factory/Main/Workbench.vue b/resources/js/components/Factory/Main/Workbench.vue
index b3d855c9..94b7e11d 100644
--- a/resources/js/components/Factory/Main/Workbench.vue
+++ b/resources/js/components/Factory/Main/Workbench.vue
@@ -3,6 +3,7 @@
v-if="workbench.block"
class="relative mx-12 flex h-full max-w-xl flex-col justify-between px-4 pt-8 xl:mx-24"
>
+
@@ -25,6 +26,7 @@ import BlockMessage from "./BlockMessage.vue";
import BlockType from "./BlockType.vue";
import BlockInteractions from "./BlockInteractions.vue";
import GroupSettings from "./GroupSettings.vue";
+import SaveStatus from "@/components/Factory/Shared/SaveStatus.vue";
import { provide } from "vue";
const workbench = useWorkbench();
diff --git a/resources/js/components/Factory/Settings/SettingsSave.test.ts b/resources/js/components/Factory/Settings/SettingsSave.test.ts
index 00b53756..ae3adef7 100644
--- a/resources/js/components/Factory/Settings/SettingsSave.test.ts
+++ b/resources/js/components/Factory/Settings/SettingsSave.test.ts
@@ -47,7 +47,7 @@ describe.each([
expect(button.attributes("aria-busy")).toBe("false");
expect(useNotice().message).toBe(
- "Your change was not saved: The name field is required.",
+ "Your change wasn't saved: The name field is required.",
);
});
diff --git a/resources/js/components/Factory/Shared/SaveStatus.test.ts b/resources/js/components/Factory/Shared/SaveStatus.test.ts
new file mode 100644
index 00000000..acbf6d3b
--- /dev/null
+++ b/resources/js/components/Factory/Shared/SaveStatus.test.ts
@@ -0,0 +1,63 @@
+import { mount, VueWrapper } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import SaveStatus from "./SaveStatus.vue";
+import { useNotice } from "@/stores/notice";
+
+let wrapper: VueWrapper;
+const live = () => wrapper.get("[aria-live=polite]");
+const retry = () => wrapper.findAll("button").find((b) => b.text() === "Retry");
+
+// the test pinia stubs actions, so set the state directly
+describe("save status", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ useNotice().$reset();
+ wrapper = mount(SaveStatus);
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ vi.useRealTimers();
+ });
+
+ it("says Saving… while a save runs, then Saved for 2 seconds", async () => {
+ expect(live().text()).toBe("");
+
+ useNotice().$patch({ runningSaves: 1 });
+ await wrapper.vm.$nextTick();
+ expect(live().text()).toBe("Saving…");
+
+ useNotice().$patch({ runningSaves: 0 });
+ await wrapper.vm.$nextTick();
+ expect(live().text()).toBe("Saved");
+
+ vi.advanceTimersByTime(2000);
+ await wrapper.vm.$nextTick();
+ expect(live().text()).toBe("");
+ });
+
+ it("says Not saved with Retry when a save failed", async () => {
+ useNotice().$patch({
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ retries: { "saveBlock:1": vi.fn() },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(live().text()).toBe("Not saved");
+ await retry()!.trigger("click");
+ expect(useNotice().retrySaves).toHaveBeenCalled();
+ });
+
+ it("shows no Saved after a failed save", async () => {
+ useNotice().$patch({ runningSaves: 1 });
+ await wrapper.vm.$nextTick();
+ useNotice().$patch({
+ runningSaves: 0,
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(live().text()).toBe("Not saved");
+ expect(retry()).toBeUndefined();
+ });
+});
diff --git a/resources/js/components/Factory/Shared/SaveStatus.vue b/resources/js/components/Factory/Shared/SaveStatus.vue
new file mode 100644
index 00000000..eb9ff8ae
--- /dev/null
+++ b/resources/js/components/Factory/Shared/SaveStatus.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+ Saving…
+
+
+
+
+
+ Saved
+
+
+
+
+
+ Not saved
+
+
+
+ Retry
+
+
+
+
+
diff --git a/resources/js/components/Factory/Shared/useSaveOnLeave.test.ts b/resources/js/components/Factory/Shared/useSaveOnLeave.test.ts
new file mode 100644
index 00000000..7bbae47d
--- /dev/null
+++ b/resources/js/components/Factory/Shared/useSaveOnLeave.test.ts
@@ -0,0 +1,54 @@
+import { mount, VueWrapper } from "@vue/test-utils";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { defineComponent } from "vue";
+import { useSaveOnLeave } from "./useSaveOnLeave";
+import { useNotice } from "@/stores/notice";
+import { useWorkbench } from "@/stores/workbench";
+
+let wrapper: VueWrapper;
+const leave = () => {
+ const event = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(event);
+ return event;
+};
+
+// the test pinia stubs actions, so set the state directly
+describe("leaving the editor", () => {
+ beforeEach(() => {
+ useNotice().$reset();
+ vi.clearAllMocks();
+ wrapper = mount(
+ defineComponent({
+ setup: () => useSaveOnLeave(),
+ template: "
",
+ }),
+ );
+ });
+
+ afterEach(() => wrapper.unmount());
+
+ it("sends waiting saves at once when the page goes away", () => {
+ window.dispatchEvent(new Event("pagehide"));
+
+ expect(useWorkbench().flushSaves).toHaveBeenCalled();
+ });
+
+ it("leaves without asking when everything is saved", () => {
+ expect(leave().defaultPrevented).toBe(false);
+ expect(useWorkbench().flushSaves).toHaveBeenCalled();
+ });
+
+ it("asks before leaving while a save waits", () => {
+ useNotice().$patch({ waitingSaves: { "saveBlock:1": true } });
+
+ expect(leave().defaultPrevented).toBe(true);
+ });
+
+ it("asks before leaving while a save failed", () => {
+ useNotice().$patch({
+ failedSaves: { "saveBlock:1": "Your change wasn't saved." },
+ });
+
+ expect(leave().defaultPrevented).toBe(true);
+ });
+});
diff --git a/resources/js/components/Factory/Shared/useSaveOnLeave.ts b/resources/js/components/Factory/Shared/useSaveOnLeave.ts
new file mode 100644
index 00000000..75dcc927
--- /dev/null
+++ b/resources/js/components/Factory/Shared/useSaveOnLeave.ts
@@ -0,0 +1,21 @@
+import { useEventListener } from "@vueuse/core";
+import { useWorkbench } from "@/stores";
+import { useNotice } from "@/stores/notice";
+
+// a reload or a closed tab within the save's debounce would drop the change
+export function useSaveOnLeave(): void {
+ const notice = useNotice();
+ const workbench = useWorkbench();
+
+ useEventListener(window, "pagehide", () => workbench.flushSaves());
+
+ useEventListener(window, "beforeunload", (event: BeforeUnloadEvent) => {
+ workbench.flushSaves();
+
+ if (notice.saveStatus !== "idle") {
+ // the browser asks before leaving; returnValue for older browsers
+ event.preventDefault();
+ event.returnValue = true;
+ }
+ });
+}
diff --git a/resources/js/stores/form.ts b/resources/js/stores/form.ts
index 59053ec3..7f87624d 100644
--- a/resources/js/stores/form.ts
+++ b/resources/js/stores/form.ts
@@ -239,16 +239,14 @@ export const useForm = defineStore("form", {
insertAfter: FormBlockModel | null = null,
type: FormBlockType | null = null,
) {
- if (!this.form) {
+ const form = this.form;
+
+ if (!form) {
return;
}
- try {
- const response = await callCreateFormBlock(
- this.form.uuid,
- type,
- );
- useNotice().clearSaveError("createFormBlock");
+ await useNotice().send("createFormBlock", async () => {
+ const response = await callCreateFormBlock(form.uuid, type);
const newBlock = response.data;
@@ -270,9 +268,7 @@ export const useForm = defineStore("form", {
workbench.putOnWorkbench(newBlock);
}
- } catch (error) {
- useNotice().showSaveError(error, "createFormBlock");
- }
+ });
},
async deleteFormBlock(block: FormBlockModel) {
@@ -282,9 +278,8 @@ export const useForm = defineStore("form", {
const save = `deleteFormBlock:${block.id}`;
- try {
+ const request = async () => {
const response = await callDeleteFormBlock(block.id);
- useNotice().clearSaveError(save);
if (response.status === 200) {
const index = this.blocks?.findIndex((item) => {
@@ -306,9 +301,9 @@ export const useForm = defineStore("form", {
});
}
}
- } catch (error) {
- useNotice().showSaveError(error, save, block);
- }
+ };
+
+ await useNotice().send(save, request, block);
},
async updateFormBlockProperty(
@@ -322,12 +317,11 @@ export const useForm = defineStore("form", {
const save = `updateFormBlockProperty:${block.id}:${property}`;
- try {
+ const request = async () => {
const response = await callUpdateFormBlock({
...block,
[property]: newValue,
});
- useNotice().clearSaveError(save);
const index = this.blocks?.findIndex(
(item) => item.id === block.id,
@@ -340,9 +334,9 @@ export const useForm = defineStore("form", {
) {
this.blocks[index] = response.data;
}
- } catch (error) {
- useNotice().showSaveError(error, save, block);
- }
+ };
+
+ await useNotice().send(save, request, block);
},
async changeBlockSequence(
@@ -411,15 +405,11 @@ export const useForm = defineStore("form", {
},
);
- try {
- await callUpdateBlockSequence(
- this.form.uuid,
- saveSequenceRequestData,
- );
- useNotice().clearSaveError("blockSequence");
- } catch (error) {
- useNotice().showSaveError(error, "blockSequence");
- }
+ const form = this.form;
+
+ await useNotice().send("blockSequence", () =>
+ callUpdateBlockSequence(form.uuid, saveSequenceRequestData),
+ );
},
},
});
diff --git a/resources/js/stores/logic.ts b/resources/js/stores/logic.ts
index d41e1387..572257be 100644
--- a/resources/js/stores/logic.ts
+++ b/resources/js/stores/logic.ts
@@ -83,20 +83,20 @@ export const useLogic = defineStore("logic", {
return;
}
- const save = `removeRule:${this.block.logics[index].id}`;
+ // a retry removes this rule, even after the logic editor closed
+ const block = this.block;
+ const logics = this.block.logics;
+ const logic = logics[index];
- try {
- const response = await callDeleteFormBlockLogic(
- this.block.logics[index],
- );
- useNotice().clearSaveError(save);
+ const request = async () => {
+ const response = await callDeleteFormBlockLogic(logic);
- if (response.status === 200) {
- this.block.logics.splice(index, 1);
+ if (response.status === 200 && logics.includes(logic)) {
+ logics.splice(logics.indexOf(logic), 1);
}
- } catch (error) {
- useNotice().showSaveError(error, save, this.block);
- }
+ };
+
+ await useNotice().send(`removeRule:${logic.id}`, request, block);
},
async saveBlockLogic() {
diff --git a/resources/js/stores/notice.test.ts b/resources/js/stores/notice.test.ts
index 2479b33d..fb6eebf4 100644
--- a/resources/js/stores/notice.test.ts
+++ b/resources/js/stores/notice.test.ts
@@ -1,6 +1,7 @@
import { AxiosError, AxiosResponse } from "axios";
import { createPinia, setActivePinia } from "pinia";
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useForm } from "./form";
import { useNotice } from "./notice";
const httpError = (status: number, data: unknown = {}) =>
@@ -22,7 +23,7 @@ describe("notice store", () => {
notice.showSaveError(refused, "saveInteraction:1");
expect(notice.message).toBe(
- "Your change was not saved: The options.allowed files field must be an integer.",
+ "Your change wasn't saved: The options.allowed files field must be an integer.",
);
});
@@ -40,7 +41,7 @@ describe("notice store", () => {
);
expect(notice.message).toBe(
- "Your change was not saved: your session has expired. Reload the page and try again.",
+ "Your change wasn't saved: your session has expired. Reload the page and try again.",
);
},
);
@@ -54,28 +55,48 @@ describe("notice store", () => {
notice.showSaveError(error, "saveInteraction:1");
expect(notice.message).toBe(
- "Your change was not saved. Check your connection and try again.",
+ "Your change wasn't saved. Check your connection and try again.",
);
});
- it("names the question by its title, else by its id", () => {
+ it("names the question by its number and text", () => {
const notice = useNotice();
-
- notice.showSaveError(refused, "saveBlock:1", {
- title: null,
- uuid: "Ab12",
- } as FormBlockModel);
-
+ useForm().blocks = [
+ { id: 1, uuid: "b1", type: "input-short", message: "Name
" },
+ { id: 2, uuid: "g1", type: "group", title: "Travel" },
+ {
+ id: 3,
+ uuid: "b3",
+ type: "radio",
+ message: "Will you come to the meetup ?
",
+ parent_block: "g1",
+ },
+ { id: 4, uuid: "b4", type: "input-short", message: null },
+ ] as FormBlockModel[];
+ const [, group, question, untitled] = useForm().blocks!;
+
+ notice.showSaveError(refused, "saveBlock:3", question);
expect(notice.message).toBe(
- 'Your change to "Ab12" was not saved: The options.allowed files field must be an integer.',
+ "Your change to question 2 (“Will you come to the…”) wasn't saved: The options.allowed files field must be an integer.",
);
- notice.showSaveError(refused, "saveBlock:2", {
- title: "Your name",
- uuid: "Cd34",
+ notice.showSaveError(refused, "saveBlock:4", untitled);
+ expect(notice.message).toMatch(/^Your change to question 3 wasn't/);
+
+ notice.showSaveError(refused, "saveBlock:2", group);
+ expect(notice.message).toMatch(/^Your change to group “Travel” wasn't/);
+ });
+
+ it("names a question that isn't in the list by its text", () => {
+ const notice = useNotice();
+
+ notice.showSaveError(refused, "saveBlock:9", {
+ id: 9,
+ type: "input-short",
+ message: "Your name
",
} as FormBlockModel);
- expect(notice.message).toMatch(/^Your change to "Your name" was not/);
+ expect(notice.message).toMatch(/^Your change to “Your name” wasn't/);
});
it("clears the message when the same save works later", () => {
@@ -93,17 +114,79 @@ describe("notice store", () => {
notice.showSaveError(refused, "saveInteraction:1");
notice.clearSaveError("saveInteraction:2");
- expect(notice.message).toMatch(/^Your change was not saved/);
+ expect(notice.message).toMatch(/^Your change wasn't saved/);
+ });
+
+ it("shows the hidden bar again for a new failure", () => {
+ const notice = useNotice();
+
+ notice.showSaveError(refused, "saveInteraction:1");
+ notice.isBarHidden = true;
+ notice.showSaveError(refused, "saveInteraction:2");
+
+ expect(notice.isBarHidden).toBe(false);
});
it("shows an older failure again when the newer one is fixed", () => {
const notice = useNotice();
- const block = { title: "Your name" } as FormBlockModel;
+ const block = { message: "Your name" } as FormBlockModel;
notice.showSaveError(refused, "saveBlock:1", block);
notice.showSaveError(refused, "saveInteraction:2");
notice.clearSaveError("saveInteraction:2");
- expect(notice.message).toMatch(/^Your change to "Your name"/);
+ expect(notice.message).toMatch(/^Your change to “Your name”/);
+ });
+
+ it("says Saving… while a save waits or runs, and nothing once it worked", async () => {
+ const notice = useNotice();
+ let done!: () => void;
+
+ notice.waitForSave("saveBlock:1");
+ expect(notice.saveStatus).toBe("saving");
+
+ const save = notice.send(
+ "saveBlock:1",
+ () => new Promise((resolve) => (done = resolve)),
+ );
+ expect(notice.saveStatus).toBe("saving");
+
+ done();
+ await save;
+ expect(notice.saveStatus).toBe("idle");
+ });
+
+ it("keeps Saving… while the next change waits behind a running save", async () => {
+ const notice = useNotice();
+ let done!: () => void;
+
+ const save = notice.send(
+ "saveBlock:1",
+ () => new Promise((resolve) => (done = resolve)),
+ );
+ notice.waitForSave("saveBlock:1");
+ done();
+ await save;
+
+ expect(notice.saveStatus).toBe("saving");
+ });
+
+ it("says a save failed, and Retry sends it again", async () => {
+ const notice = useNotice();
+ const request = vi
+ .fn()
+ .mockRejectedValueOnce(refused)
+ .mockResolvedValueOnce("ok");
+
+ await notice.send("saveBlock:1", request);
+ expect(notice.saveStatus).toBe("failed");
+ expect(notice.canRetry).toBe(true);
+
+ notice.retrySaves();
+ expect(notice.canRetry).toBe(false);
+ await vi.waitFor(() => expect(notice.saveStatus).toBe("idle"));
+
+ expect(request).toHaveBeenCalledTimes(2);
+ expect(notice.message).toBeNull();
});
});
diff --git a/resources/js/stores/notice.ts b/resources/js/stores/notice.ts
index 7ed041d1..80a38a05 100644
--- a/resources/js/stores/notice.ts
+++ b/resources/js/stores/notice.ts
@@ -1,56 +1,142 @@
import { AxiosError } from "axios";
import { defineStore } from "pinia";
+import { getTextFromHtml } from "@/utils";
+import { useForm } from "./form";
interface NoticeStore {
// message per failed save, e.g. "saveInteraction:12", newest last
failedSaves: Record;
+ // how to send a failed save again, by the same name
+ retries: Record Promise>;
+ // saves that wait for their debounce, and saves that run
+ waitingSaves: Record;
+ runningSaves: number;
+ // the owner closed the red bar; a new failure shows it again
+ isBarHidden: boolean;
+}
+
+export type SaveStatus = "saving" | "failed" | "idle";
+
+const questions = (nodes: TreeNode[]): FormBlockModel[] =>
+ nodes.flatMap((node) =>
+ node.block.type === "group" ? questions(node.children) : [node.block],
+ );
+
+// "question 4 (“Will you come to the…”)", as the editor numbers questions
+function questionName(block: FormBlockModel): string {
+ if (block.type === "group") {
+ return `group “${block.title || "Unnamed group"}”`;
+ }
+
+ const text = getTextFromHtml(block.message ?? "").trim();
+ const short = text.length > 22 ? `${text.slice(0, 20).trim()}…` : text;
+ const quoted = short ? `“${short}”` : "";
+ const number =
+ questions(useForm().blocksTree).findIndex(
+ (item) => item.id === block.id,
+ ) + 1;
+
+ if (!number) {
+ return quoted;
+ }
+
+ return quoted ? `question ${number} (${quoted})` : `question ${number}`;
}
export const useNotice = defineStore("notice", {
state: (): NoticeStore => {
return {
failedSaves: {},
+ retries: {},
+ waitingSaves: {},
+ runningSaves: 0,
+ isBarHidden: false,
};
},
getters: {
message: (state): string | null =>
Object.values(state.failedSaves).at(-1) ?? null,
+
+ saveStatus: (state): SaveStatus => {
+ if (state.runningSaves || Object.keys(state.waitingSaves).length) {
+ return "saving";
+ }
+
+ return Object.keys(state.failedSaves).length ? "failed" : "idle";
+ },
+
+ canRetry: (state): boolean => Object.keys(state.retries).length > 0,
},
actions: {
+ waitForSave(save: string) {
+ this.waitingSaves[save] = true;
+ },
+
+ // runs a save, and keeps it to send again if it fails
+ async send(
+ save: string,
+ request: () => Promise,
+ block?: FormBlockModel | null,
+ ): Promise {
+ delete this.waitingSaves[save];
+ this.runningSaves++;
+
+ try {
+ const result = await request();
+ this.clearSaveError(save);
+
+ return result;
+ } catch (error) {
+ this.showSaveError(error, save, block);
+ this.retries[save] = () => this.send(save, request, block);
+ } finally {
+ this.runningSaves--;
+ }
+ },
+
+ retrySaves() {
+ const retries = Object.values(this.retries);
+
+ // a second click while they run would send them twice
+ this.retries = {};
+ retries.forEach((retry) => retry());
+ },
+
showSaveError(
error: unknown,
save: string,
block?: FormBlockModel | null,
) {
- const change = block
- ? `Your change to "${block.title || block.uuid}"`
- : "Your change";
+ const name = block ? questionName(block) : "";
+ const change = name ? `Your change to ${name}` : "Your change";
const response =
error instanceof AxiosError ? error.response : undefined;
// delete first, so a repeated failure moves to the end and shows
delete this.failedSaves[save];
+ this.isBarHidden = false;
if (response?.status === 401 || response?.status === 419) {
this.failedSaves[save] =
- `${change} was not saved: your session has expired. Reload the page and try again.`;
+ `${change} wasn't saved: your session has expired. Reload the page and try again.`;
} else if (
response &&
response.status < 500 &&
response.data?.message
) {
this.failedSaves[save] =
- `${change} was not saved: ${response.data.message}`;
+ `${change} wasn't saved: ${response.data.message}`;
} else {
this.failedSaves[save] =
- `${change} was not saved. Check your connection and try again.`;
+ `${change} wasn't saved. Check your connection and try again.`;
}
},
clearSaveError(save: string) {
delete this.failedSaves[save];
+ delete this.retries[save];
},
},
});
diff --git a/resources/js/stores/workbench.test.ts b/resources/js/stores/workbench.test.ts
index 2e3608a2..9e3eb783 100644
--- a/resources/js/stores/workbench.test.ts
+++ b/resources/js/stores/workbench.test.ts
@@ -1,15 +1,24 @@
import { AxiosError, AxiosResponse } from "axios";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createApp } from "vue";
import { useForm } from "./form";
import { useNotice } from "./notice";
import { useWorkbench } from "./workbench";
+import { PiniaDebounce } from "@pinia/plugin-debounce";
+import debounce from "lodash/debounce";
+import { callUpdateFormBlock } from "@/api/blocks";
import { callUpdateFormBlockInteraction } from "@/api/interactions";
vi.mock("@/api/interactions", () => ({
callUpdateFormBlockInteraction: vi.fn(),
}));
+vi.mock("@/api/blocks", async (importOriginal) => ({
+ ...(await importOriginal()),
+ callUpdateFormBlock: vi.fn(),
+}));
+
const interaction = {
id: 1,
type: "file",
@@ -38,14 +47,19 @@ describe("workbench store", () => {
it("shows the server's reason and the question when a save is refused", async () => {
useForm().blocks = [
- { id: 3, title: "Your files", uuid: "Ab12" } as FormBlockModel,
+ {
+ id: 3,
+ uuid: "Ab12",
+ type: "file",
+ message: "Your files
",
+ } as FormBlockModel,
];
refuse();
await useWorkbench().saveInteraction(interaction);
expect(useNotice().message).toBe(
- 'Your change to "Your files" was not saved: The options.allowed files field must be an integer.',
+ "Your change to question 1 (“Your files”) wasn't saved: The options.allowed files field must be an integer.",
);
});
@@ -57,7 +71,7 @@ describe("workbench store", () => {
await useWorkbench().saveInteraction(interaction);
expect(useNotice().message).toBe(
- "Your change was not saved. Check your connection and try again.",
+ "Your change wasn't saved. Check your connection and try again.",
);
});
@@ -86,6 +100,62 @@ describe("workbench store", () => {
accept();
await useWorkbench().saveInteraction(otherInteraction);
- expect(useNotice().message).toMatch(/^Your change was not saved:/);
+ expect(useNotice().message).toMatch(/^Your change wasn't saved:/);
+ });
+});
+
+describe("leaving the page", () => {
+ const keepalive = { adapter: "fetch", fetchOptions: { keepalive: true } };
+
+ const typeInBlock = (message: string) => {
+ const workbench = useWorkbench();
+ workbench.block = { id: 3, message: "Hi
" } as FormBlockModel;
+ workbench.updateBlock({ message });
+
+ return workbench;
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // the debounce the app uses, so a save waits like in the editor
+ // (plugins run only once pinia is in an app)
+ const pinia = createPinia();
+ pinia.use(PiniaDebounce(debounce));
+ createApp({}).use(pinia);
+ setActivePinia(pinia);
+ vi.mocked(callUpdateFormBlock).mockResolvedValue({} as AxiosResponse);
+ });
+
+ it("sends a waiting save with fetch keepalive, which outlives the page", () => {
+ const workbench = typeInBlock("Hi there
");
+ expect(callUpdateFormBlock).not.toHaveBeenCalled();
+
+ workbench.flushSaves();
+
+ expect(callUpdateFormBlock).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "Hi there
" }),
+ keepalive,
+ );
+ });
+
+ it("sends a save over keepalive's 64 KB limit the usual way", () => {
+ typeInBlock(`${"a".repeat(70_000)}
`).flushSaves();
+
+ expect(callUpdateFormBlock).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ );
+ });
+
+ it("sends saves without keepalive while the page stays", () => {
+ vi.useFakeTimers();
+ typeInBlock("Hi there
");
+ vi.advanceTimersByTime(1000);
+ vi.useRealTimers();
+
+ expect(callUpdateFormBlock).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ );
});
});
diff --git a/resources/js/stores/workbench.ts b/resources/js/stores/workbench.ts
index edefe363..adbb47ed 100644
--- a/resources/js/stores/workbench.ts
+++ b/resources/js/stores/workbench.ts
@@ -1,5 +1,6 @@
import { useForm } from "@/stores/form";
import { callUpdateFormBlock } from "@/api/blocks";
+import { keepAlive } from "@/api/handler";
import { defineStore } from "pinia";
import { DebouncedFunc } from "lodash";
import {
@@ -12,6 +13,9 @@ import { replaceRouteQuery } from "@/utils";
import { useNotice } from "./notice";
import useActiveInteractions from "@/components/Factory/Shared/useActiveInteractions";
+// true while flushSaves sends the waiting saves as the page closes
+let isLeaving = false;
+
interface WorkbenchStore {
block: FormBlockModel | null;
isSavingInteraction: number | null;
@@ -89,22 +93,30 @@ export const useWorkbench = defineStore("workbench", {
});
if (this.block) {
+ useNotice().waitForSave(`saveBlock:${this.block.id}`);
this.saveBlock({ ...this.block });
}
},
async saveBlock(block: FormBlockModel) {
- const save = `saveBlock:${block.id}`;
+ const config = isLeaving ? keepAlive(block) : undefined;
- try {
- const response = await callUpdateFormBlock(block);
- useNotice().clearSaveError(save);
+ await useNotice().send(
+ `saveBlock:${block.id}`,
+ () => callUpdateFormBlock(block, config),
+ block,
+ );
+ },
- if (response.status === 200) {
- console.info("saved block success");
- }
- } catch (error) {
- useNotice().showSaveError(error, save, block);
+ // sends the saves that wait for their debounce now, as the page closes
+ flushSaves() {
+ isLeaving = true;
+
+ try {
+ (this.saveBlock as DebouncedFunc).flush();
+ (this.saveInteraction as DebouncedFunc).flush();
+ } finally {
+ isLeaving = false;
}
},
@@ -112,28 +124,30 @@ export const useWorkbench = defineStore("workbench", {
type: FormBlockInteractionModel["type"],
attributes?: Partial,
): Promise {
- if (!this.block) {
+ // a retry adds the answer to this block, even after another opened
+ const block = this.block;
+
+ if (!block) {
return;
}
- const save = `createInteraction:${this.block.id}`;
-
- try {
- const response = await callCreateFormBlockInteraction(
- this.block?.id,
- type,
- attributes,
- );
- useNotice().clearSaveError(save);
-
- if (response.status === 201) {
- this.block.interactions?.push(response.data);
-
- return response.data;
- }
- } catch (error) {
- useNotice().showSaveError(error, save, this.block);
- }
+ return useNotice().send(
+ `createInteraction:${block.id}`,
+ async () => {
+ const response = await callCreateFormBlockInteraction(
+ block.id,
+ type,
+ attributes,
+ );
+
+ if (response.status === 201) {
+ block.interactions?.push(response.data);
+
+ return response.data;
+ }
+ },
+ block,
+ );
},
updateInteraction(
@@ -170,54 +184,50 @@ export const useWorkbench = defineStore("workbench", {
}
this.isSavingInteraction = pointer.id;
+ useNotice().waitForSave(`saveInteraction:${pointer.id}`);
this.saveInteraction({ ...pointer });
},
async deleteInteraction(interaction: { id: number }) {
- const index = this.block?.interactions?.findIndex((item) => {
- return interaction.id === item.id;
- });
+ // a retry removes the answer from this block, even after another opened
+ const block = this.block;
+ const interactions = block?.interactions;
- if (
- !this.block?.interactions ||
- typeof index === "undefined" ||
- index === -1
- )
+ if (!interactions?.some((item) => item.id === interaction.id)) {
return;
-
- const save = `deleteInteraction:${interaction.id}`;
-
- try {
- await callDeleteFormBlockInteraction(
- interaction as FormBlockInteractionModel,
- );
- useNotice().clearSaveError(save);
-
- this.block.interactions.splice(index, 1);
- } catch (error) {
- useNotice().showSaveError(error, save, this.block);
}
+
+ await useNotice().send(
+ `deleteInteraction:${interaction.id}`,
+ async () => {
+ await callDeleteFormBlockInteraction(
+ interaction as FormBlockInteractionModel,
+ );
+
+ const index = interactions.findIndex(
+ (item) => item.id === interaction.id,
+ );
+
+ if (index !== -1) {
+ interactions.splice(index, 1);
+ }
+ },
+ block,
+ );
},
async saveInteraction(interaction: FormBlockInteractionModel) {
- const save = `saveInteraction:${interaction.id}`;
-
- try {
- const response =
- await callUpdateFormBlockInteraction(interaction);
- useNotice().clearSaveError(save);
-
- if (response.status === 200) {
- console.info("saving interaction success");
- }
- } catch (error) {
- // the debounced save can run after the owner opened another block
- const block = useForm().blocks?.find(
- (item) => item.id === interaction.form_block_id,
- );
+ // the debounced save can run after the owner opened another block
+ const block = useForm().blocks?.find(
+ (item) => item.id === interaction.form_block_id,
+ );
+ const config = isLeaving ? keepAlive(interaction) : undefined;
- useNotice().showSaveError(error, save, block);
- }
+ await useNotice().send(
+ `saveInteraction:${interaction.id}`,
+ () => callUpdateFormBlockInteraction(interaction, config),
+ block,
+ );
},
async changeInteractionSequence(from: number, to: number) {
@@ -252,17 +262,17 @@ export const useWorkbench = defineStore("workbench", {
}
});
- const save = `interactionSequence:${this.block.id}`;
+ const block = this.block;
- try {
- await callUpdateInteractionSequence(
- this.block.id,
- saveSequenceRequestData,
- );
- useNotice().clearSaveError(save);
- } catch (error) {
- useNotice().showSaveError(error, save, this.block);
- }
+ await useNotice().send(
+ `interactionSequence:${block.id}`,
+ () =>
+ callUpdateInteractionSequence(
+ block.id,
+ saveSequenceRequestData,
+ ),
+ block,
+ );
},
},