diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f645e8..03b8b241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Lines marked **Upgrade step** need you to act after updating. - Opening a question in the editor no longer saves its answer settings, so "Saving…" does not show up for nothing. They save only when you change a setting. (#288) - When a save fails, the red bar with Retry no longer makes the editor taller than your window. The page stays as tall as the window, so your last question and the end screen stay in reach. (#290) - Long answers are now accepted up to what the database can hold (about 36,000 plain letters, fewer with accents, emoji or non-Latin script), and a longer one is refused with a clear message and nothing is saved. Before, the limit was lower (about 30,000 plain letters) and the refusal was a technical message. (#293) +- Typing 2.5 into "Max characters" or 11 into "Height in lines" of a long text question no longer saves the 2 or 1 typed on the way. The value saved before stays until you type one that fits. (#295) ### Security diff --git a/resources/js/components/Factory/Main/ConfigureTextarea.test.ts b/resources/js/components/Factory/Main/ConfigureTextarea.test.ts index 7e6433ee..611555b3 100644 --- a/resources/js/components/Factory/Main/ConfigureTextarea.test.ts +++ b/resources/js/components/Factory/Main/ConfigureTextarea.test.ts @@ -1,13 +1,23 @@ import { flushPromises, mount } from "@vue/test-utils"; +import { AxiosResponse } from "axios"; import { createPinia, setActivePinia } from "pinia"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "vue"; +import { PiniaDebounce } from "@pinia/plugin-debounce"; +import debounce from "lodash/debounce"; import ConfigureTextarea from "./ConfigureTextarea.vue"; import { useWorkbench } from "@/stores"; +import { useNotice } from "@/stores/notice"; +import { callUpdateFormBlockInteraction } from "@/api/interactions"; const saved = vi.hoisted(() => ({ options: {} as Record, })); +vi.mock("@/api/interactions", () => ({ + callUpdateFormBlockInteraction: vi.fn(), +})); + vi.mock("../utils/useInteractionsUtils", () => ({ useInteractionsUtils: () => ({ findOrCreate: async () => ({ @@ -180,3 +190,113 @@ describe("half-typed numbers", () => { expect(save.mock.lastCall?.[0].options.max_chars).toBeNull(); }); }); + +describe("typed key by key", () => { + afterEach(() => vi.useRealTimers()); + + // an answer saved with height 5 and limit 500, saving like in the editor + const open = async () => { + saved.options = { rows: 5, max_chars: 500 }; + // the debounce the app uses (plugins run only once pinia is in an app) + const pinia = createPinia(); + pinia.use(PiniaDebounce(debounce)); + createApp({}).use(pinia); + setActivePinia(pinia); + useWorkbench().block = { + id: 3, + interactions: [ + { + id: 7, + type: "textarea", + form_block_id: 3, + label: "", + options: { ...saved.options }, + }, + ], + } as unknown as FormBlockModel; + vi.mocked(callUpdateFormBlockInteraction).mockReset(); + vi.mocked(callUpdateFormBlockInteraction).mockResolvedValue( + {} as AxiosResponse, + ); + + const wrapper = mount(ConfigureTextarea, { + global: { plugins: [pinia], provide: { block: { type: "input" } } }, + }); + await flushPromises(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + + // one key: the field's value and whether the browser flags it as half typed + const press = async (selector: string, value: string, half = false) => { + const input = wrapper.find(selector); + Object.defineProperty(input.element, "validity", { + value: { badInput: half }, + configurable: true, + }); + await input.setValue(value); + await flushPromises(); + }; + // the options of each save that reached the server + const sent = async () => { + vi.advanceTimersByTime(1000); + await flushPromises(); + + return vi + .mocked(callUpdateFormBlockInteraction) + .mock.calls.map(([interaction]) => interaction.options); + }; + + return { wrapper, press, sent }; + }; + + it("keeps 500 when the owner types 2.5, not the 2 on the way", async () => { + const { wrapper, press, sent } = await open(); + + await press("input#textarea-max-chars", "2"); + await press("input#textarea-max-chars", "", true); + await press("input#textarea-max-chars", "2.5"); + + expect(await sent()).toEqual([{ rows: 5, max_chars: 500 }]); + expect(wrapper.find("#textarea-max-chars-error").exists()).toBe(true); + }); + + it("keeps height 5 when the owner types 11, not the 1 on the way", async () => { + const { wrapper, press, sent } = await open(); + + await press("input#textarea-rows", "1"); + await press("input#textarea-rows", "11"); + + expect(await sent()).toEqual([{ rows: 5, max_chars: 500 }]); + expect(wrapper.find("#textarea-rows-error").exists()).toBe(true); + }); + + it("saves height 10 without the 1 on the way", async () => { + const { press, sent } = await open(); + + await press("input#textarea-rows", "1"); + await press("input#textarea-rows", "10"); + + expect(await sent()).toEqual([{ rows: 10, max_chars: 500 }]); + }); + + it("still saves the height changed just before a wrong limit", async () => { + const { press, sent } = await open(); + + await press("input#textarea-rows", "7"); + await press("input#textarea-max-chars", "2"); + await press("input#textarea-max-chars", "-2"); + + expect(await sent()).toEqual([{ rows: 7, max_chars: 500 }]); + }); + + it("saves 800 over 500 and ends saved", async () => { + const { press, sent } = await open(); + + await press("input#textarea-max-chars", "8"); + await press("input#textarea-max-chars", "80"); + await press("input#textarea-max-chars", "800"); + expect(useNotice().saveStatus).toBe("saving"); + + expect(await sent()).toEqual([{ rows: 5, max_chars: 800 }]); + expect(useNotice().saveStatus).toBe("idle"); + }); +}); diff --git a/resources/js/components/Factory/Main/ConfigureTextarea.vue b/resources/js/components/Factory/Main/ConfigureTextarea.vue index 7514eb33..9675dfea 100644 --- a/resources/js/components/Factory/Main/ConfigureTextarea.vue +++ b/resources/js/components/Factory/Main/ConfigureTextarea.vue @@ -114,6 +114,13 @@ onMounted(async () => { const settled = useSettingsWatch( [label, rows, maxChars, rowsBad, maxCharsBad], (newValues) => { + // a wrong number is never saved, nor the number typed on the way to it + if (!rowsValid.value) { + workbench.keepSentOption(interaction.value.id, "rows"); + } + if (!maxCharsValid.value) { + workbench.keepSentOption(interaction.value.id, "max_chars"); + } if (!rowsValid.value || !maxCharsValid.value) return; const update = { diff --git a/resources/js/stores/workbench.ts b/resources/js/stores/workbench.ts index 2d61c2a3..4a0d8daa 100644 --- a/resources/js/stores/workbench.ts +++ b/resources/js/stores/workbench.ts @@ -15,6 +15,8 @@ import useActiveInteractions from "@/components/Factory/Shared/useActiveInteract // true while flushSaves sends the waiting saves as the page closes let isLeaving = false; +// the answer as sent, before the changes that wait for their debounce +let sentInteraction: FormBlockInteractionModel | undefined; interface WorkbenchStore { block: FormBlockModel | null; @@ -174,6 +176,10 @@ export const useWorkbench = defineStore("workbench", { const pointer = this.block?.interactions[index]; + if (!useNotice().waitingSaves[`saveInteraction:${pointer.id}`]) { + sentInteraction = { ...pointer }; + } + Object.entries(interaction).forEach((value) => { const key = value[0]; const setting = value[1]; @@ -196,6 +202,30 @@ export const useWorkbench = defineStore("workbench", { this.saveInteraction({ ...pointer }); }, + // a number on the way to a wrong one, e.g. "2" before "2.5", is no + // change: the waiting save sends the option as it was sent before + keepSentOption(id: number, option: keyof FormBlockInteractionSettings) { + const pointer = this.block?.interactions?.find( + (item) => item.id === id, + ); + + if ( + !pointer || + sentInteraction?.id !== id || + !useNotice().waitingSaves[`saveInteraction:${id}`] + ) { + return; + } + + this.updateInteraction({ + id, + options: { + ...pointer.options, + [option]: sentInteraction.options?.[option], + }, + }); + }, + async deleteInteraction(interaction: { id: number }) { // a retry removes the answer from this block, even after another opened const block = this.block;