diff --git a/CHANGELOG.md b/CHANGELOG.md index 448a15af..406b1e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Lines marked **Upgrade step** need you to act after updating. ### Fixed +- A long text question with a limit of 100 characters now accepts exactly 100 characters and warns from 101. "Max characters" and "Height in lines" refuse 0, negative and fractional numbers and say why; leave them empty for no limit or the default height. An empty height stays empty when you open the question again. (#292) - Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) - A new long text question now shows in your form and in the editor preview, also before you set any options for it. Before, it failed to show. (#286) - A long text question with an empty "Max characters" now has no limit, also when you open it again. Before, the editor showed 500 and saved it back. New long text questions still start at 500. (#291) diff --git a/resources/js/components/Factory/Main/ConfigureTextarea.test.ts b/resources/js/components/Factory/Main/ConfigureTextarea.test.ts index e15f4743..7e6433ee 100644 --- a/resources/js/components/Factory/Main/ConfigureTextarea.test.ts +++ b/resources/js/components/Factory/Main/ConfigureTextarea.test.ts @@ -10,7 +10,11 @@ const saved = vi.hoisted(() => ({ vi.mock("../utils/useInteractionsUtils", () => ({ useInteractionsUtils: () => ({ - findOrCreate: async () => ({ id: 7, label: "", options: saved.options }), + findOrCreate: async () => ({ + id: 7, + label: "", + options: saved.options, + }), }), })); @@ -40,8 +44,10 @@ describe("long text max characters", () => { const { wrapper, save } = await reopen({ rows: 5, max_chars: null }); expect( - (wrapper.find("input#textarea-max-chars").element as HTMLInputElement) - .value, + ( + wrapper.find("input#textarea-max-chars") + .element as HTMLInputElement + ).value, ).toBe(""); expect(save).not.toHaveBeenCalled(); }); @@ -50,8 +56,10 @@ describe("long text max characters", () => { const { wrapper } = await reopen({ rows: 5, max_chars: 120 }); expect( - (wrapper.find("input#textarea-max-chars").element as HTMLInputElement) - .value, + ( + wrapper.find("input#textarea-max-chars") + .element as HTMLInputElement + ).value, ).toBe("120"); }); @@ -65,3 +73,110 @@ describe("long text max characters", () => { expect(save.mock.lastCall?.[0].options.max_chars).toBeNull(); }); }); + +describe("long text max characters, bad values", () => { + const maxInput = (wrapper: Awaited>["wrapper"]) => + wrapper.find("input#textarea-max-chars"); + + it.each(["0", "-5", "2.5"])( + "never saves %s and says why", + async (value) => { + const { wrapper, save } = await reopen({ rows: 5, max_chars: 500 }); + + await maxInput(wrapper).setValue(value); + await flushPromises(); + + expect(save).not.toHaveBeenCalled(); + expect(wrapper.find("#textarea-max-chars-error").exists()).toBe( + true, + ); + }, + ); + + it.each([0, -3])("shows a saved %s as empty", async (saved) => { + const { wrapper } = await reopen({ rows: 5, max_chars: saved }); + + expect((maxInput(wrapper).element as HTMLInputElement).value).toBe(""); + }); +}); + +describe("long text height", () => { + const rowsInput = ( + wrapper: Awaited>["wrapper"], + ) => wrapper.find("input#textarea-rows"); + + it("saves no height when the owner clears it, and keeps it empty on reopen", async () => { + const { wrapper, save } = await reopen({ rows: 5, max_chars: 500 }); + + await rowsInput(wrapper).setValue(""); + await flushPromises(); + + expect(save.mock.lastCall?.[0].options.rows).toBeNull(); + + const reopened = await reopen({ rows: null, max_chars: 500 }); + expect( + (rowsInput(reopened.wrapper).element as HTMLInputElement).value, + ).toBe(""); + expect(reopened.save).not.toHaveBeenCalled(); + }); + + it.each(["0", "-1", "2.5", "11"])( + "never saves a height of %s", + async (value) => { + const { wrapper, save } = await reopen({ rows: 5, max_chars: 500 }); + + await rowsInput(wrapper).setValue(value); + await flushPromises(); + + expect(save).not.toHaveBeenCalled(); + expect(wrapper.find("#textarea-rows-error").exists()).toBe(true); + }, + ); +}); + +describe("half-typed numbers", () => { + // the browser reads a half-typed "-" or "2." as an empty value and flags it + const typeHalf = async (selector: string, wrapper: any) => { + const input = wrapper.find(selector); + Object.defineProperty(input.element, "validity", { + value: { badInput: true }, + configurable: true, + }); + await input.setValue(""); + await flushPromises(); + }; + + it.each([ + [ + "max characters", + "input#textarea-max-chars", + "#textarea-max-chars-error", + ], + ["height", "input#textarea-rows", "#textarea-rows-error"], + ])( + "does not save %s as empty while it holds a bad number", + async (_, input, error) => { + const { wrapper, save } = await reopen({ rows: 5, max_chars: 500 }); + + await typeHalf(input, wrapper); + + expect(save).not.toHaveBeenCalled(); + expect(wrapper.find(error).exists()).toBe(true); + }, + ); + + it("saves no limit once the owner deletes the half-typed sign", async () => { + const { wrapper, save } = await reopen({ rows: 5, max_chars: 500 }); + await typeHalf("input#textarea-max-chars", wrapper); + + const input = wrapper.find("input#textarea-max-chars"); + Object.defineProperty(input.element, "validity", { + value: { badInput: false }, + configurable: true, + }); + await input.setValue(""); + await flushPromises(); + + expect(save.mock.lastCall?.[0].options.max_chars).toBeNull(); + }); +}); diff --git a/resources/js/components/Factory/Main/ConfigureTextarea.vue b/resources/js/components/Factory/Main/ConfigureTextarea.vue index bf271507..7514eb33 100644 --- a/resources/js/components/Factory/Main/ConfigureTextarea.vue +++ b/resources/js/components/Factory/Main/ConfigureTextarea.vue @@ -20,12 +20,21 @@ id="textarea-rows" placeholder="e.g. 4" type="number" - :min="1" - :max="10" step="1" block v-model="rows" + @input="rowsBad = isBadInput($event)" + :aria-invalid="!rowsValid" + aria-describedby="textarea-rows-error" /> +
@@ -35,7 +44,18 @@ type="number" block v-model="maxChars" + @input="maxCharsBad = isBadInput($event)" + :aria-invalid="!maxCharsValid" + aria-describedby="textarea-max-chars-error" /> +
@@ -43,7 +63,7 @@ diff --git a/resources/js/forms/classic/interactions/useTextareaAction.test.ts b/resources/js/forms/classic/interactions/useTextareaAction.test.ts new file mode 100644 index 00000000..b06c2e65 --- /dev/null +++ b/resources/js/forms/classic/interactions/useTextareaAction.test.ts @@ -0,0 +1,30 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import CharCount from "@/components/CharCount.vue"; +import { useTextareaAction } from "./useTextareaAction"; + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +const limitOf = (max: number) => + useTextareaAction({ + type: "input-long", + is_required: false, + interactions: [{ options: { max_chars: max } }], + } as unknown as PublicFormBlockModel).validator; + +describe("long text limit edge", () => { + it("accepts exactly N characters and refuses N + 1", () => { + const validate = limitOf(5); + + expect(validate({ payload: "a".repeat(5) }).valid).toBe(true); + expect(validate({ payload: "a".repeat(6) }).valid).toBe(false); + }); + + it("shows the counter in the warning color only above N", () => { + const counter = (text: string) => + mount(CharCount, { props: { text, maxChars: 5 } }).find("span"); + + expect(counter("a".repeat(5)).classes()).not.toContain("text-red-600"); + expect(counter("a".repeat(6)).classes()).toContain("text-red-600"); + }); +}); diff --git a/resources/js/forms/classic/interactions/useTextareaAction.ts b/resources/js/forms/classic/interactions/useTextareaAction.ts index cede6432..38fab513 100644 --- a/resources/js/forms/classic/interactions/useTextareaAction.ts +++ b/resources/js/forms/classic/interactions/useTextareaAction.ts @@ -20,7 +20,7 @@ export function useTextareaAction(block: PublicFormBlockModel) { action.options?.max_chars && action.options?.max_chars > 0 ) { - if (input?.payload?.length >= action.options.max_chars) { + if (input?.payload?.length > action.options.max_chars) { return { valid: false, message: diff --git a/resources/js/types/models.d.ts b/resources/js/types/models.d.ts index b6d4232d..8ca46f06 100644 --- a/resources/js/types/models.d.ts +++ b/resources/js/types/models.d.ts @@ -166,7 +166,7 @@ type FormBlockInteractionType = | "date"; type FormBlockInteractionSettings = { - rows?: number; + rows?: number | null; max_chars?: number | null; minDate?: string; maxDate?: string;