diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b3cb50..080fb42a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Lines marked **Upgrade step** need you to act after updating. - The form list shows when each form last got a submission, with a blue dot for the last 24 hours, and now opens sorted by latest submission. Its date column shows the date the list is sorted by. (#250) - The API can copy a question (`POST /api/blocks/{block}/duplicate`) with its text, settings, answers and Required, but not its rules or its Identifier, so links that fill in answers and webhooks can't mix the two up. The copy goes right after the original. (#262) - The public form API has a preview mode for the form's team: start a session with `preview: true`, also for 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) ### Changed diff --git a/resources/js/api/conversation.ts b/resources/js/api/conversation.ts index 23fc417c..15feb54b 100644 --- a/resources/js/api/conversation.ts +++ b/resources/js/api/conversation.ts @@ -47,6 +47,7 @@ export async function callGetFormStoryboard( export async function callCreateFormSession( uuid: string, params: Record, + preview: boolean = false, ): Promise> { try { const { route } = await useRoutes(); @@ -56,9 +57,10 @@ export async function callCreateFormSession( }); if (resolvedRoute) { - const response = await handler.post(resolvedRoute, { - params, - }); + const response = await handler.post( + resolvedRoute, + preview ? { params, preview } : { params }, + ); return Promise.resolve(response as AxiosResponse); } else { return Promise.reject("route not found"); diff --git a/resources/js/forms/classic/ClassicForm.vue b/resources/js/forms/classic/ClassicForm.vue index 51ddcfae..e2316082 100644 --- a/resources/js/forms/classic/ClassicForm.vue +++ b/resources/js/forms/classic/ClassicForm.vue @@ -75,6 +75,7 @@ import { storeToRefs } from "pinia"; import { useI18n } from "vue-i18n"; import { useRouteParams } from "@/utils/useRouteParams"; import { useRoutes } from "@/utils/useRoutes"; +import { usePreviewMessages } from "@/forms/classic/usePreviewMessages"; const props = defineProps<{ settings: PublicFormModel; @@ -90,6 +91,10 @@ provide("disableFocus", focusDisabled); // init routes await useRoutes(); +// the editor's preview runs on the form page, an embed on another site never does +const query = new URLSearchParams(window.location.search); +const isPreview = !!window.iptSettings && query.get("preview") === "1"; + // init conversation store const store = useConversation(); await store.initForm( @@ -100,9 +105,15 @@ await store.initForm( "hideNavigation", "focusOnMount", "alignLeft", + ...(isPreview ? ["preview", "block"] : []), ]), + isPreview ? { block: query.get("block") } : null, ); +if (isPreview) { + usePreviewMessages(); +} + const { locale } = useI18n({ useScope: "global" }); locale.value = store.form?.language ?? "en"; diff --git a/resources/js/forms/classic/usePreviewMessages.test.ts b/resources/js/forms/classic/usePreviewMessages.test.ts new file mode 100644 index 00000000..ffa07697 --- /dev/null +++ b/resources/js/forms/classic/usePreviewMessages.test.ts @@ -0,0 +1,60 @@ +/// +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { effectScope, nextTick } from 'vue'; +import { useConversation } from '@/stores/conversation'; +import { usePreviewMessages } from './usePreviewMessages'; + +const block = (id: string) => ({ id, type: 'input-short', parent_block: null, interactions: [] }) as unknown as PublicFormBlockModel; + +// jsdom has no frames: the window is its own parent, so it stands in for the editor +const fromEditor = (data: unknown, init: MessageEventInit = {}) => + window.dispatchEvent(new MessageEvent('message', { data, origin: window.location.origin, source: window, ...init })); + +describe('usePreviewMessages', () => { + let scope: ReturnType; + let store: ReturnType; + let postMessage: ReturnType; + + beforeEach(() => { + setActivePinia(createPinia()); + store = useConversation(); + store.queue = [block('q1'), block('q2'), block('q3')]; + store.current = 'q1'; + postMessage = vi.spyOn(window, 'postMessage').mockImplementation(() => {}); + + scope = effectScope(); + scope.run(() => usePreviewMessages()); + }); + + afterEach(() => { + scope.stop(); + postMessage.mockRestore(); + }); + + it('tells the editor which question shows, only on its own origin', async () => { + expect(postMessage).toHaveBeenLastCalledWith({ type: 'input:preview:block', block: 'q1' }, window.location.origin); + + store.current = 'q2'; + await nextTick(); + + expect(postMessage).toHaveBeenLastCalledWith({ type: 'input:preview:block', block: 'q2' }, window.location.origin); + }); + + it('goes to the question the editor asks for', () => { + fromEditor({ type: 'input:preview:goto', block: 'q3' }); + + expect(store.current).toBe('q3'); + }); + + it.each([ + ['a message from another origin', { type: 'input:preview:goto', block: 'q3' }, { origin: 'https://evil.example' }], + ['a message from another window', { type: 'input:preview:goto', block: 'q3' }, { source: null }], + ['another message type', { type: 'other', block: 'q3' }, {}], + ['a block that is not a string', { type: 'input:preview:goto', block: ['q3'] }, {}], + ])('ignores %s', (_, data, init) => { + fromEditor(data, init); + + expect(store.current).toBe('q1'); + }); +}); diff --git a/resources/js/forms/classic/usePreviewMessages.ts b/resources/js/forms/classic/usePreviewMessages.ts new file mode 100644 index 00000000..b418514c --- /dev/null +++ b/resources/js/forms/classic/usePreviewMessages.ts @@ -0,0 +1,42 @@ +import { watch } from "vue"; +import { useEventListener } from "@vueuse/core"; +import { useConversation } from "@/stores/conversation"; + +// messages between the editor and the form in its preview frame, both on the same origin +export type PreviewMessage = + // the form shows this question + | { type: "input:preview:block"; block: string } + // the editor asks for this question + | { type: "input:preview:goto"; block: string }; + +export function usePreviewMessages() { + const store = useConversation(); + const origin = window.location.origin; + + watch( + () => store.current, + (block) => { + if (block) { + const message: PreviewMessage = { + type: "input:preview:block", + block, + }; + window.parent.postMessage(message, origin); + } + }, + { immediate: true }, + ); + + useEventListener(window, "message", (event: MessageEvent) => { + if ( + event.origin !== origin || + event.source !== window.parent || + event.data?.type !== "input:preview:goto" || + typeof event.data.block !== "string" + ) { + return; + } + + store.startAt(event.data.block); + }); +} diff --git a/resources/js/stores/conversation.test.ts b/resources/js/stores/conversation.test.ts index 379bde82..bdc60359 100644 --- a/resources/js/stores/conversation.test.ts +++ b/resources/js/stores/conversation.test.ts @@ -5,7 +5,7 @@ import { effectScope, nextTick } from 'vue'; import { useConversation } from './conversation'; import * as logicHelpers from './helpers/logic'; import { createFlatQueue } from './helpers/queue'; -import { callGetFormStoryboard, callSubmitForm } from '@/api/conversation'; +import { callCreateFormSession, callGetFormStoryboard, callSubmitForm } from '@/api/conversation'; import handler from '@/api/handler'; import { useBeforeUnload } from '@/utils/useBeforeUnload'; @@ -711,4 +711,97 @@ describe('Conversation Store', () => { }); }); }); + + describe('preview', () => { + const initPreview = async (block: string | null = null) => { + const store = useConversation(); + vi.mocked(callGetFormStoryboard).mockResolvedValue({ data: { blocks: makeBlocks('q1', 'q2', 'q3') } } as any); + + await store.initForm({ uuid: 'form' } as PublicFormModel, {}, { block }); + + return store; + }; + + const submitLastQuestion = async (store: ReturnType) => { + store.current = 'q3'; + store.session = { token: 'preview' } as FormSessionModel; + vi.mocked(logicHelpers.evaluateGotoLogic).mockReturnValue(null); + + await store.next(); + }; + + it('asks for a preview session, and only in preview', async () => { + await initPreview(); + expect(callCreateFormSession).toHaveBeenLastCalledWith('form', {}, true); + + setActivePinia(createPinia()); + await useConversation().initForm({ uuid: 'form' } as PublicFormModel, {}); + expect(callCreateFormSession).toHaveBeenLastCalledWith('form', {}, false); + }); + + it('starts at the given question, and Back goes to the one before it', async () => { + const store = await initPreview('q2'); + expect(store.current).toBe('q2'); + + store.back(); + expect(store.current).toBe('q1'); + }); + + it('starts at the first question when the given one is not in the queue', async () => { + const store = await initPreview('unknown'); + + expect(store.current).toBe('q1'); + }); + + it('shows the error state when the server refuses the preview', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(callCreateFormSession).mockRejectedValueOnce(new Error('Request failed with status code 403')); + + const store = await initPreview(); + + expect(store.form).toBeUndefined(); + }); + + it('goes to a question the editor asks for, also from the end page', async () => { + const store = await initPreview(); + store.isSubmitted = true; + + store.startAt('q3'); + + expect(store.current).toBe('q3'); + expect(store.path).toEqual(['q1', 'q2']); + expect(store.isSubmitted).toBe(false); + }); + + it('skips the redirect after the last question', async () => { + const store = await initPreview(); + store.form = { uuid: 'form', use_cta_redirect: true, cta_link: 'https://example.com/thanks' } as PublicFormModel; + const navigation = vi.fn(); + jsdom.virtualConsole.on('jsdomError', navigation); + + await submitLastQuestion(store); + + expect(store.isSubmitted).toBe(true); + expect(navigation).not.toHaveBeenCalled(); + jsdom.virtualConsole.off('jsdomError', navigation); + }); + + it('skips the file uploads', async () => { + const store = await initPreview(); + store.payload = { q3: { payload: [new File(['a'], 'a.txt')], actionId: 'files' } }; + const upload = vi.spyOn(handler, 'post').mockResolvedValue({}); + + await submitLastQuestion(store); + + expect(store.isSubmitted).toBe(true); + expect(upload).not.toHaveBeenCalled(); + }); + + it('never asks "Leave site?": a preview stores nothing', async () => { + const store = await initPreview(); + store.payload = { q1: { payload: 'yes', actionId: '1' } }; + + expect(store.hasUnsavedPayload.value).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/resources/js/stores/conversation.ts b/resources/js/stores/conversation.ts index 3244c531..6d7f56bc 100644 --- a/resources/js/stores/conversation.ts +++ b/resources/js/stores/conversation.ts @@ -25,6 +25,8 @@ type ConversationStore = { isSubmitted: boolean; submitFailed: boolean; isInputMode: boolean; + // the editor's preview: the server stores nothing, so skip uploads and the redirect + isPreview: boolean; uploads: FormFileUploads; uploadedFiles: File[]; }; @@ -43,6 +45,7 @@ export const useConversation = defineStore("form", { isSubmitted: false, submitFailed: false, isInputMode: false, + isPreview: false, uploads: {}, uploadedFiles: [], }; @@ -209,7 +212,8 @@ export const useConversation = defineStore("form", { */ hasUnsavedPayload(state): Ref { return ref( - !state.isSubmitted && + !state.isPreview && + !state.isSubmitted && state.payload && Object.keys(state.payload).length > 0, ); @@ -310,7 +314,10 @@ export const useConversation = defineStore("form", { async initForm( initialPayload: string | PublicFormModel, params: Record, + preview: { block: string | null } | null = null, ) { + this.isPreview = preview !== null; + const id = typeof initialPayload === "string" ? initialPayload @@ -328,9 +335,24 @@ export const useConversation = defineStore("form", { } } - const [formSessionResponse, storyboardResponse] = await Promise.all( - [callCreateFormSession(id, params), callGetFormStoryboard(id)], - ); + const responses = await Promise.all([ + callCreateFormSession(id, params, this.isPreview), + callGetFormStoryboard(id), + ]).catch((error) => { + // only the form's team may preview: anyone else gets 403 and sees the error state + if (!this.isPreview) { + throw error; + } + + console.warn(error); + }); + + if (!responses) { + this.form = undefined; + return; + } + + const [formSessionResponse, storyboardResponse] = responses; this.session = formSessionResponse.data; this.storyboard = storyboardResponse.data.blocks; @@ -339,6 +361,25 @@ export const useConversation = defineStore("form", { this.payload = prefillPayload(this.storyboard, params); this.current = this.processedQueue[0].id ?? null; + + if (preview?.block) { + this.startAt(preview.block); + } + }, + + // the preview starts at any shown question, as if the respondent had come along the way to it + startAt(blockId: string) { + const index = this.findBlockIndex(blockId); + + if (index === -1) { + return; + } + + this.path = this.processedQueue + .slice(0, index) + .map((block) => block.id); + this.current = blockId; + this.isSubmitted = false; }, enableInputMode() { @@ -478,7 +519,7 @@ export const useConversation = defineStore("form", { this.hasFileUploads, ); - if (this.hasFileUploads) { + if (this.hasFileUploads && !this.isPreview) { // init file upload state this.initFileUpload(); @@ -521,6 +562,7 @@ export const useConversation = defineStore("form", { // If a redirect is configured, we redirect the user to the given url if ( + !this.isPreview && this.form.use_cta_redirect && isAllowedLink(this.callToActionUrl, ["http:", "https:"]) ) {