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

Expand Down
122 changes: 121 additions & 1 deletion resources/js/components/Factory/Main/ConfigureTextarea.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
}));

vi.mock("@/api/interactions", () => ({
callUpdateFormBlockInteraction: vi.fn(),
}));

vi.mock("../utils/useInteractionsUtils", () => ({
useInteractionsUtils: () => ({
findOrCreate: async () => ({
Expand Down Expand Up @@ -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");
});
});
7 changes: 7 additions & 0 deletions resources/js/components/Factory/Main/ConfigureTextarea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
30 changes: 30 additions & 0 deletions resources/js/stores/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand All @@ -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;
Expand Down
Loading