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

Expand Down
8 changes: 5 additions & 3 deletions resources/js/api/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export async function callGetFormStoryboard(
export async function callCreateFormSession(
uuid: string,
params: Record<string, string>,
preview: boolean = false,
): Promise<AxiosResponse<FormSessionModel>> {
try {
const { route } = await useRoutes();
Expand All @@ -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<FormSessionModel>);
} else {
return Promise.reject("route not found");
Expand Down
11 changes: 11 additions & 0 deletions resources/js/forms/classic/ClassicForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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";

Expand Down
60 changes: 60 additions & 0 deletions resources/js/forms/classic/usePreviewMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/// <reference types="vitest/jsdom" />
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<typeof effectScope>;
let store: ReturnType<typeof useConversation>;
let postMessage: ReturnType<typeof vi.spyOn>;

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');
});
});
42 changes: 42 additions & 0 deletions resources/js/forms/classic/usePreviewMessages.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
95 changes: 94 additions & 1 deletion resources/js/stores/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof useConversation>) => {
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);
});
});
});
52 changes: 47 additions & 5 deletions resources/js/stores/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand All @@ -43,6 +45,7 @@ export const useConversation = defineStore("form", {
isSubmitted: false,
submitFailed: false,
isInputMode: false,
isPreview: false,
uploads: {},
uploadedFiles: [],
};
Expand Down Expand Up @@ -209,7 +212,8 @@ export const useConversation = defineStore("form", {
*/
hasUnsavedPayload(state): Ref<boolean> {
return ref(
!state.isSubmitted &&
!state.isPreview &&
!state.isSubmitted &&
state.payload &&
Object.keys(state.payload).length > 0,
);
Expand Down Expand Up @@ -310,7 +314,10 @@ export const useConversation = defineStore("form", {
async initForm(
initialPayload: string | PublicFormModel,
params: Record<string, string>,
preview: { block: string | null } | null = null,
) {
this.isPreview = preview !== null;

const id =
typeof initialPayload === "string"
? initialPayload
Expand All @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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:"])
) {
Expand Down
Loading