Skip to content
Closed
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Lines marked **Upgrade step** need you to act after updating.
- The form's team can try a form in preview mode, also while it's a draft. Answers in preview mode are never stored or counted and send no webhooks or emails. (#266)
- Add `?preview=1` to your form's link to try it as a member of its team. Nothing is stored, files aren't uploaded and the form doesn't redirect at the end. (#270)
- Add a question right where you need it: between questions or from a question's menu, and type "/" in an empty question to pick its answer type. A picker groups the answer types as Text, Choice, Rating, Other and Content, and the new question opens ready to type. The answer types have new names, like Single choice, Short text and Text only. (#276)
- The form editor shows the end screen as the last block of your form. You can edit its headline, text and button right there, and the Completion Page in Settings shows the same values. (#271)
- The form editor shows the end screen as the last block of your form. You can edit its headline, text and button right there, and the End screen tab in Settings shows the same values. (#271)
- "Preview" in the editor shows your form beside the questions, on tablet and phone as a panel. It follows the question you edit, and the editor follows when you click Next in it. On the other form pages, "Preview" opens your form in a new tab in preview mode. Answers in either preview aren't saved. (#273)

### Changed
Expand All @@ -30,6 +30,7 @@ Lines marked **Upgrade step** need you to act after updating.
- Deleting a question, a group, an answer choice, a consent policy or a logic rule in the form editor now asks first in the app's own dialog. It names the question, how many answers will be deleted with it, and the questions whose rules will stop working. A choice nobody picked, or a policy nobody agreed to, is still deleted at once. (#269)
- Each rule now shows as a sentence under its question, and a group says when it's hidden. Edit changes a rule right there, and for choices, ratings and scales you pick the answer instead of typing it. A rule whose question was deleted shows in red and can't be saved until you fix it. (#274)
- The form editor uses plain words: a question's settings are headed by its answer type, like Choices or Rating, "Identifier" is now "Short name", and every switch shows on or off. Settings name what they do, like "Lowest value", "Shuffle choices" and "None ticked: all types allowed." on file uploads. (#279)
- The editor's preview shows the end screen while you edit it, with your latest text, and says so when your form sends people to a link instead. Its button opens in a new tab there, so you stay in the editor, and typing in a question you just opened no longer goes into the preview. On a tablet or phone, Esc closes the preview even after you clicked into it. Settings now calls the Completion Page "End screen", like the editor. (#280)

### Fixed

Expand Down
21 changes: 21 additions & 0 deletions resources/js/Pages/Forms/Edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
import { callGetForm } from "@/api/forms";
import Edit from "./Edit.vue";
import FormMenu from "@/components/Factory/TopBar/FormMenu.vue";
import PreviewFrame from "@/components/Factory/Preview/PreviewFrame.vue";
import { useForm, useWorkbench } from "@/stores";

vi.mock("@/api/forms", async (importOriginal) => ({
Expand Down Expand Up @@ -164,6 +165,26 @@ describe("editor loading", () => {
expect(preview?.attributes("aria-haspopup")).toBe("dialog");
});

it("closes the preview on Esc inside it and puts focus back on Preview", async () => {
const editor = await mountEditor(loads(blocks), {
stubs: { ...stubs, FormPreview: false, PreviewFrame: true },
mocks: { route: () => "/" },
});
const preview = editor
.findAll("button")
.find((button) => button.text() === "Preview");
await preview?.trigger("click");
await flushPromises();
expect(document.activeElement?.closest("[role=dialog]")).not.toBeNull();

// after a click into the frame, the form there gets the key and tells the editor
editor.findComponent(PreviewFrame).vm.$emit("escape");
await flushPromises();

expect(document.querySelector("[role=dialog]")).toBeNull();
expect(document.activeElement).toBe(preview?.element);
});

it("puts focus back on the form menu when a preview opened from it closes", async () => {
// the menu item is gone by then, so the dialog can't focus it again
const editor = await mountEditor(loads(blocks), {
Expand Down
2 changes: 1 addition & 1 deletion resources/js/Pages/Forms/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const navigation: Array<{
{ name: "Options", component: Options, slug: "options" },
{ name: "Privacy", component: Privacy, slug: "privacy" },
{
name: "Completion Page",
name: "End screen",
component: CompletionPage,
slug: "completion-page",
},
Expand Down
21 changes: 21 additions & 0 deletions resources/js/components/Factory/Document/EndScreenBlock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useNotice } from "@/stores/notice";
import { useFreshForm } from "@/utils/useFreshForm";
import CompletionPage from "@/components/Factory/Settings/CompletionPage.vue";
import EndScreenBlock from "./EndScreenBlock.vue";
import { endScreenOpen } from "./blockFocus";

vi.mock("@/api/forms", async (importOriginal) => ({
...(await importOriginal()),
Expand All @@ -30,6 +31,8 @@ let wrapper: VueWrapper | undefined;

// real actions, not the stubs of the global testing pinia
const mountBlock = () => {
// one at a time, as in the document: they share the open state
wrapper?.unmount();
wrapper = mount(EndScreenBlock, {
attachTo: document.body,
global: { plugins: [pinia], directives: { tooltip: {} } },
Expand Down Expand Up @@ -119,6 +122,15 @@ describe("the end screen in the document", () => {
expect(block.find("#end-screen-headline").exists()).toBe(false);
});

it("tells the preview while it's open, and starts closed on the next page", async () => {
const block = await open();
expect(endScreenOpen.value).toBe(true);

block.unmount();
wrapper = undefined;
expect(endScreenOpen.value).toBe(false);
});

it("has the question card's phone sizes, number column and focus bar", async () => {
const block = await open();
const [number, card] = block.findAll("#row-end-screen > div");
Expand Down Expand Up @@ -447,6 +459,15 @@ describe("the end screen in the document", () => {
);
});

// the public form only goes to a web link, else it shows the end screen
it("shows the end screen when the link isn't a web link", () => {
useForm().form = { ...form, use_cta_redirect: true, cta_link: "mailto:hi@example.com" };
const block = mountBlock();

expect(block.text()).not.toContain("No end screen");
expect(block.find("button").text()).toContain("See you on Thursday!");
});

it("stays in step with the Settings page through the form store", async () => {
const block = await open();
await block.find("#end-screen-headline").setValue("Thanks!");
Expand Down
18 changes: 14 additions & 4 deletions resources/js/components/Factory/Document/EndScreenBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

<!-- the public form then skips the end screen -->
<div
v-if="store.form?.use_cta_redirect"
v-if="redirects"
class="min-h-14 min-w-0 flex-1 px-3 py-2.5 text-sm text-grey-700"
>
<p class="break-words">
<span class="font-medium text-grey-900">No end screen:</span>
people go to {{ store.form.cta_link }}
people go to {{ store.form?.cta_link }}
</p>
<Link :href="settingsLink" class="text-xs text-grey-600 underline"
>Change in Settings</Link
Expand Down Expand Up @@ -127,28 +127,37 @@
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, reactive, watch } from "vue";
import { Link } from "@inertiajs/vue3";
import { D9Icon, D9Input, D9Label, D9Switch } from "@deck9/ui";
import { useEventListener } from "@vueuse/core";
import debounce from "lodash/debounce";
import { useForm, useWorkbench } from "@/stores";
import { useNotice } from "@/stores/notice";
import { keepAlive } from "@/api/handler";
import { isAllowedLink } from "@/utils/sanitize";
import Editor from "@/components/Factory/Shared/Editor.vue";
import { freshFormLoaded, trackFormSave } from "@/utils/useFreshForm";
import { plainText } from "./questionSummary";
import { endScreenOpen } from "./blockFocus";

const store = useForm();
const workbench = useWorkbench();
const notice = useNotice();

const isOpen = ref(false);
// shared, so the preview shows the end screen while it's open
const isOpen = endScreenOpen;

const settingsLink = computed(
() =>
`${window.route("forms.settings", { uuid: store.form?.uuid })}#completion-page`,
);
// the public form only goes to a web link, else it shows the end screen
const redirects = computed(
() =>
store.form?.use_cta_redirect &&
isAllowedLink(store.form.cta_link, ["http:", "https:"]),
);
const text = computed(() => plainText(store.form?.eoc_text ?? null));
// the public form shows the button only with a link
const hasButton = computed(
Expand Down Expand Up @@ -263,5 +272,6 @@ watch(
onBeforeUnmount(() => {
save.cancel();
delete notice.waitingSaves[SAVE];
isOpen.value = false;
});
</script>
3 changes: 3 additions & 0 deletions resources/js/components/Factory/Document/blockFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { useForm, useWorkbench } from "@/stores";
// the question whose card takes the cursor once it shows: its text editor isn't there at once
export const cursorFor = ref<string | null>(null);

// the end screen's card is open: it's no block, so the workbench doesn't hold it
export const endScreenOpen = ref(false);

// puts the cursor in a question's text, or in a group's name
export async function focusBlock(block: FormBlockModel | null): Promise<boolean> {
if (!block) {
Expand Down
49 changes: 49 additions & 0 deletions resources/js/components/Factory/Preview/FormPreview.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useForm, useWorkbench } from "@/stores";
import { END_SCREEN } from "@/forms/classic/previewTarget";
import FormPreview from "./FormPreview.vue";
import PreviewToggle from "./PreviewToggle.vue";

Expand Down Expand Up @@ -149,6 +150,54 @@ describe("the preview panel", () => {
);
});

it("says when it shows the end screen", async () => {
screen(true);
useForm().form = { use_cta_redirect: false } as FormModel;
const wrapper = mountPreview();

wrapper.findComponent(PreviewFrame).vm.$emit("update:shown", END_SCREEN);
await flushPromises();

expect(wrapper.get("#preview-heading").text()).toBe("Preview · end screen");
expect(wrapper.find("iframe").isVisible()).toBe(true);
});

// the preview doesn't follow the link, so its end screen is one nobody sees
it.each([true, false])("says where people go instead of the end screen when the form sends them to a link (desktop: %s)", async (wide) => {
screen(wide);
useForm().blocks = [{ uuid: "q1", type: "input-short" }] as FormBlockModel[];
useForm().form = { use_cta_redirect: true, cta_link: "https://example.com/thanks" } as FormModel;
const wrapper = mountPreview();
await flushPromises();

const frame = wrapper.findComponent(PreviewFrame);
frame.vm.$emit("update:shown", END_SCREEN);
await flushPromises();

const panel = document.querySelector(wide ? "aside" : "[role=dialog]");
expect(panel?.textContent).toContain("No end screen: people go to https://example.com/thanks.");
expect(frame.isVisible()).toBe(false);

// back at a question, the frame shows again
frame.vm.$emit("update:shown", "q1");
await flushPromises();
expect(document.body.textContent).not.toContain("No end screen");
expect(frame.isVisible()).toBe(true);
});

it("shows the end screen when the link isn't a web link, as the public form does", async () => {
screen(true);
useForm().form = { use_cta_redirect: true, cta_link: "mailto:hi@example.com" } as FormModel;
const wrapper = mountPreview();

const frame = wrapper.findComponent(PreviewFrame);
frame.vm.$emit("update:shown", END_SCREEN);
await flushPromises();

expect(wrapper.text()).not.toContain("No end screen");
expect(frame.isVisible()).toBe(true);
});

it.each([true, false])("shows nothing when closed (desktop: %s)", (wide) => {
screen(wide);
const wrapper = mountPreview(false);
Expand Down
39 changes: 37 additions & 2 deletions resources/js/components/Factory/Preview/FormPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
<p v-if="hidden" class="w-80 max-w-full text-xs text-grey-600">
{{ hidden }}, so the preview can't jump to it.
</p>
<p v-if="redirect" class="w-80 max-w-full break-words text-sm text-grey-600">
No end screen: people go to {{ redirect }}.
</p>
<p v-if="isEmpty" class="w-80 max-w-full text-sm text-grey-600">
Add a question to see it here.
</p>
<PreviewFrame v-else v-model:shown="shown" v-model:missed="missed" />
<PreviewFrame
v-else
v-show="!redirect"
v-model:shown="shown"
v-model:missed="missed"
/>
</aside>

<!-- tablet and phone: over the document; closing it keeps it mounted, so focus goes back to Preview -->
Expand All @@ -39,10 +47,23 @@
<p v-if="hidden" class="w-80 max-w-full text-xs text-grey-600">
{{ hidden }}, so the preview can't jump to it.
</p>
<p
v-if="redirect"
class="w-80 max-w-full break-words text-sm text-grey-600"
>
No end screen: people go to {{ redirect }}.
</p>
<p v-if="isEmpty" class="w-80 max-w-full text-sm text-grey-600">
Add a question to see it here.
</p>
<PreviewFrame v-else v-model:shown="shown" v-model:missed="missed" />
<!-- Esc after a click into the frame goes to the frame, not to the dialog -->
<PreviewFrame
v-else
v-show="!redirect"
v-model:shown="shown"
v-model:missed="missed"
@escape="open = false"
/>
</DialogPanel>
</Dialog>
</template>
Expand All @@ -54,6 +75,8 @@ import { useMediaQuery } from "@vueuse/core";
import { computed, ref, watch } from "vue";
import { useForm, useWorkbench } from "@/stores";
import { questionNumbers } from "@/components/Factory/Document/questionSummary";
import { END_SCREEN } from "@/forms/classic/previewTarget";
import { isAllowedLink } from "@/utils/sanitize";
import PreviewFrame from "./PreviewFrame.vue";

const open = defineModel<boolean>({ required: true });
Expand All @@ -68,9 +91,21 @@ const numbers = computed(() => questionNumbers(store.blocks ?? []));
const shown = ref<string | null>(null);
const missed = ref<string | null>(null);
const heading = computed(() => {
if (shown.value === END_SCREEN) {
return "Preview · end screen";
}

const number = shown.value && numbers.value[shown.value];
return number ? `Preview · question ${number}` : "Preview";
});
// the preview doesn't follow the link, so its end screen is one nobody sees
const redirect = computed(() =>
shown.value === END_SCREEN &&
store.form?.use_cta_redirect &&
isAllowedLink(store.form.cta_link, ["http:", "https:"])
? store.form.cta_link
: undefined,
);
// the open question, when the preview couldn't go to it (a group has no number);
// the public form leaves out a hidden question and each one in a hidden group
const hidden = computed(() => {
Expand Down
Loading