Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
77 changes: 69 additions & 8 deletions resources/js/Jetstream/Banner.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
33 changes: 24 additions & 9 deletions resources/js/Jetstream/Banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center min-w-0">
<span
class="flex p-2 rounded-lg"
class="hidden sm:flex p-2 rounded-lg mr-3"
:class="{
'bg-blue-600': style == 'success',
'bg-red-600': style == 'danger',
Expand Down Expand Up @@ -51,18 +51,28 @@
</svg>
</span>

<p class="ml-3 font-medium text-sm text-white truncate">
<p class="font-medium text-sm text-white break-words">
{{ message }}
</p>
</div>

<div class="flex-shrink-0 sm:ml-3">
<div class="flex flex-shrink-0 items-center gap-2 sm:ml-3">
<button
v-if="failure && notice.canRetry"
type="button"
class="-mr-1 flex p-2 rounded-md focus:outline-none sm:-mr-2 transition"
class="h-11 rounded-md bg-white px-3 text-sm font-medium text-red-700 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-white sm:h-8"
@click="notice.retrySaves()"
>
Retry
</button>
<!-- below lg the bar is the only sign of a failed save and its Retry -->
<button
type="button"
class="-mr-1 flex h-11 w-11 items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white sm:-mr-2 sm:h-9 sm:w-9 transition"
:class="{
'hover:bg-blue-600 focus:bg-blue-600': style == 'success',
'hover:bg-red-600 focus:bg-red-600': style == 'danger',
'max-lg:hidden': failure && notice.canRetry,
}"
aria-label="Dismiss"
@click.prevent="dismiss"
Expand Down Expand Up @@ -105,17 +115,21 @@ export default defineComponent({
},

computed: {
failure() {
return this.notice.isBarHidden ? null : this.notice.message;
},

style() {
if (this.notice.message) {
if (this.failure) {
return "danger";
}

return this.$page.props.jetstream.flash?.bannerStyle || "success";
},

message() {
if (this.notice.message) {
return this.notice.message;
if (this.failure) {
return this.failure;
}

return (this.show && this.$page.props.jetstream.flash?.banner) || "";
Expand All @@ -124,8 +138,9 @@ export default defineComponent({

methods: {
dismiss() {
if (this.notice.message) {
this.notice.$reset();
if (this.failure) {
// hide only: the save status, Retry and the leave prompt still need it
this.notice.isBarHidden = true;
} else {
this.show = false;
}
Expand Down
3 changes: 3 additions & 0 deletions resources/js/Pages/Forms/Edit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Storyboard from "@/components/Factory/Sidebar/Storyboard.vue";
import Workbench from "@/components/Factory/Main/Workbench.vue";
import WorkbenchEmpty from "@/components/Factory/Main/WorkbenchEmpty.vue";
import { useForm, useWorkbench } from "@/stores";
import { useSaveOnLeave } from "@/components/Factory/Shared/useSaveOnLeave";
import { D9Skeleton } from "@deck9/ui";
import { onBeforeUnmount, onMounted, ref } from "vue";

Expand Down Expand Up @@ -66,6 +67,8 @@ const loadBlocks = async () => {
status.value = "loaded";
};

useSaveOnLeave();

onBeforeUnmount(() => {
store.clearForm();
});
Expand Down
8 changes: 5 additions & 3 deletions resources/js/api/blocks.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -45,13 +45,15 @@ export function callCreateFormBlock(
}

export function callUpdateFormBlock(
block: FormBlockModel
block: FormBlockModel,
config?: AxiosRequestConfig
): Promise<AxiosResponse<FormBlockModel>> {
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<FormBlockModel>);
Expand Down
9 changes: 8 additions & 1 deletion resources/js/api/handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { AxiosRequestConfig } from "axios";
import csrfToken from "./interceptors/csrfToken";

const handler = axios.create({
Expand All @@ -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;
8 changes: 5 additions & 3 deletions resources/js/api/interactions.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -25,15 +25,17 @@ export function callCreateFormBlockInteraction(
}

export function callUpdateFormBlockInteraction(
interaction: FormBlockInteractionModel
interaction: FormBlockInteractionModel,
config?: AxiosRequestConfig
): Promise<AxiosResponse<FormBlockInteractionModel>> {
return new Promise(async (resolve, reject) => {
try {
const response = await handler.post(
window.route("api.interactions.update", {
interaction: interaction.id,
}),
interaction
interaction,
config
);

resolve(response as AxiosResponse<FormBlockInteractionModel>);
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/Factory/Main/Workbench.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<SaveStatus class="absolute right-4 top-1 h-6" />
<div class="space-y-8 pb-24">
<template v-if="workbench.block.type === 'group'">
<GroupSettings />
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
});

Expand Down
63 changes: 63 additions & 0 deletions resources/js/components/Factory/Shared/SaveStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading