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 @@
+

{{ message }}

-
+
+ +