Skip to content

Commit 3a096ab

Browse files
web-forms (Vue UI): allow host application to reset form state after submission
1 parent 3152fe7 commit 3a096ab

File tree

7 files changed

+249
-15
lines changed

7 files changed

+249
-15
lines changed

.changeset/lemon-pumpkins-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@getodk/web-forms': minor
3+
---
4+
5+
Support resetting form state after submission

packages/web-forms/src/components/OdkWebForm.vue

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
<script setup lang="ts">
2+
import type { FormStateSuccessResult } from '@/lib/init/FormState.ts';
23
import { initializeFormState } from '@/lib/init/initializeFormState.ts';
34
import type { EditInstanceOptions } from '@/lib/init/loadFormState';
45
import { loadFormState } from '@/lib/init/loadFormState';
6+
import { updateSubmittedFormState } from '@/lib/init/updateSubmittedFormState.ts';
7+
import type {
8+
HostSubmissionResultCallback,
9+
OptionalAwaitableHostSubmissionResult,
10+
} from '@/lib/submission/HostSubmissionResultCallback.ts';
511
import type {
612
ChunkedInstancePayload,
713
FetchFormAttachment,
814
MissingResourceBehavior,
915
MonolithicInstancePayload,
10-
RootNode,
1116
} from '@getodk/xforms-engine';
1217
import Button from 'primevue/button';
1318
import Card from 'primevue/card';
@@ -40,10 +45,29 @@ export interface OdkWebFormsProps {
4045
4146
const props = defineProps<OdkWebFormsProps>();
4247
48+
const hostSubmissionResultCallbackFactory = (
49+
currentState: FormStateSuccessResult
50+
): HostSubmissionResultCallback => {
51+
const handleHostSubmissionResult = async (
52+
hostResult: OptionalAwaitableHostSubmissionResult
53+
): Promise<void> => {
54+
const submissionResult = await hostResult;
55+
56+
state.value = updateSubmittedFormState(submissionResult, currentState);
57+
};
58+
59+
return (hostResult) => {
60+
void handleHostSubmissionResult(hostResult);
61+
};
62+
};
63+
4364
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- evidently a type must be used for this to be assigned to a name (which we use!); as an interface, it won't satisfy the `Record` constraint of `defineEmits`.
4465
type OdkWebFormEmits = {
45-
submit: [submissionPayload: MonolithicInstancePayload];
46-
submitChunked: [submissionPayload: ChunkedInstancePayload];
66+
submit: [submissionPayload: MonolithicInstancePayload, callback: HostSubmissionResultCallback];
67+
submitChunked: [
68+
submissionPayload: ChunkedInstancePayload,
69+
callback: HostSubmissionResultCallback,
70+
];
4771
};
4872
4973
/**
@@ -83,30 +107,32 @@ const isEmitSubscribed = (eventKey: EventKey): boolean => {
83107
return eventKey in (componentInstance?.vnode.props ?? {});
84108
};
85109
86-
const emitSubmit = async (root: RootNode) => {
110+
const emitSubmit = async (currentState: FormStateSuccessResult) => {
87111
if (isEmitSubscribed('onSubmit')) {
88-
const payload = await root.prepareInstancePayload({
112+
const payload = await currentState.root.prepareInstancePayload({
89113
payloadType: 'monolithic',
90114
});
115+
const callback = hostSubmissionResultCallbackFactory(currentState);
91116
92-
emit('submit', payload);
117+
emit('submit', payload, callback);
93118
}
94119
};
95120
96-
const emitSubmitChunked = async (root: RootNode) => {
121+
const emitSubmitChunked = async (currentState: FormStateSuccessResult) => {
97122
if (isEmitSubscribed('onSubmitChunked')) {
98123
const maxSize = props.submissionMaxSize;
99124
100125
if (maxSize == null) {
101126
throw new Error('The `submissionMaxSize` prop is required for chunked submissions');
102127
}
103128
104-
const payload = await root.prepareInstancePayload({
129+
const payload = await currentState.root.prepareInstancePayload({
105130
payloadType: 'chunked',
106131
maxSize,
107132
});
133+
const callback = hostSubmissionResultCallbackFactory(currentState);
108134
109-
emit('submitChunked', payload);
135+
emit('submitChunked', payload, callback);
110136
}
111137
};
112138
@@ -127,12 +153,14 @@ const init = async () => {
127153
128154
void init();
129155
130-
const handleSubmit = (root: RootNode) => {
156+
const handleSubmit = (currentState: FormStateSuccessResult) => {
157+
const { root } = currentState;
158+
131159
if (root.validationState.violations.length === 0) {
132160
// eslint-disable-next-line @typescript-eslint/no-floating-promises
133-
emitSubmit(root);
161+
emitSubmit(currentState);
134162
// eslint-disable-next-line @typescript-eslint/no-floating-promises
135-
emitSubmitChunked(root);
163+
emitSubmitChunked(currentState);
136164
} else {
137165
submitPressed.value = true;
138166
document.scrollingElement?.scrollTo(0, 0);
@@ -208,7 +236,7 @@ watchEffect(() => {
208236
</Card>
209237

210238
<div class="footer flex justify-content-end flex-wrap gap-3">
211-
<Button label="Send" rounded @click="handleSubmit(state.root)" />
239+
<Button label="Send" rounded @click="handleSubmit(state)" />
212240
</div>
213241
</div>
214242

packages/web-forms/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { webFormsPlugin } from './WebFormsPlugin';
22
import OdkWebForm from './components/OdkWebForm.vue';
3+
import { POST_SUBMIT__NEW_INSTANCE } from './lib/constants/control-flow.ts';
34

45
import '@fontsource/roboto/300.css';
56
import './assets/css/icomoon.css';
@@ -8,4 +9,8 @@ import './themes/2024-light/theme.scss';
89
// TODO/sk: Purge it - using postcss-purgecss
910
import 'primeflex/primeflex.css';
1011

11-
export { OdkWebForm, webFormsPlugin };
12+
/**
13+
* @todo there are almost certainly types we should be exporting from the
14+
* package entrypoint!
15+
*/
16+
export { OdkWebForm, POST_SUBMIT__NEW_INSTANCE, webFormsPlugin };
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Sentinel value, which may be used by a host application, once it has handled
3+
* submission of a form instance, indicating that Web Forms should create a new
4+
* instance of the currently loaded form.
5+
*/
6+
// In other words, if we get this from a host app like Central, we call
7+
// `form.createInstance()` and replace the current form's instance state with
8+
// the newly created instance.
9+
export const POST_SUBMIT__NEW_INSTANCE = Symbol('POST_SUBMIT__NEW_INSTANCE');
10+
export type POST_SUBMIT__NEW_INSTANCE = typeof POST_SUBMIT__NEW_INSTANCE;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { POST_SUBMIT__NEW_INSTANCE } from '../constants/control-flow.ts';
2+
import type { OptionalHostSubmissionResult } from '../submission/HostSubmissionResultCallback.ts';
3+
import { ENGINE_FORM_INSTANCE_CONFIG } from './engine-config.ts';
4+
import type { FormStateSuccessResult } from './FormState.ts';
5+
6+
/**
7+
* @todo Clean up {@link currentState}'s {@link FormStateSuccessResult.instance}
8+
* before creating a new one. (Requires engine support, but will need to be
9+
* invoked here!)
10+
*/
11+
const resetInstanceState = (currentState: FormStateSuccessResult): FormStateSuccessResult => {
12+
const { form } = currentState;
13+
const instance = form.createInstance(ENGINE_FORM_INSTANCE_CONFIG);
14+
15+
return {
16+
status: 'FORM_STATE_SUCCESS',
17+
error: null,
18+
form,
19+
instance,
20+
root: instance.root,
21+
};
22+
};
23+
24+
export const updateSubmittedFormState = (
25+
submissionResult: OptionalHostSubmissionResult,
26+
currentState: FormStateSuccessResult
27+
): FormStateSuccessResult => {
28+
if (submissionResult?.next === POST_SUBMIT__NEW_INSTANCE) {
29+
return resetInstanceState(currentState);
30+
}
31+
32+
return currentState;
33+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Awaitable } from '@getodk/common/types/helpers.js';
2+
import type { POST_SUBMIT__NEW_INSTANCE } from '../constants/control-flow.ts';
3+
4+
export interface HostSubmissionResult {
5+
readonly next?: POST_SUBMIT__NEW_INSTANCE;
6+
}
7+
8+
export type OptionalHostSubmissionResult = HostSubmissionResult | null | void;
9+
10+
export type OptionalAwaitableHostSubmissionResult = Awaitable<OptionalHostSubmissionResult>;
11+
12+
export type HostSubmissionResultCallback = (
13+
// Everything is optional!
14+
hostResult?: OptionalAwaitableHostSubmissionResult
15+
) => void;

packages/web-forms/tests/components/OdkWebForm.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import type { OdkWebFormsProps } from '@/components/OdkWebForm.vue';
22
import OdkWebForm from '@/components/OdkWebForm.vue';
3-
import type { ResolvableInstanceAttachmentsMap } from '@getodk/xforms-engine';
3+
import { POST_SUBMIT__NEW_INSTANCE } from '@/lib/constants/control-flow.ts';
4+
import type {
5+
HostSubmissionResult,
6+
HostSubmissionResultCallback,
7+
OptionalAwaitableHostSubmissionResult,
8+
} from '@/lib/submission/HostSubmissionResultCallback.ts';
9+
import type {
10+
MonolithicInstancePayload,
11+
ResolvableInstanceAttachmentsMap,
12+
} from '@getodk/xforms-engine';
413
import { flushPromises, mount } from '@vue/test-utils';
514
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
615
import {
@@ -13,6 +22,10 @@ import {
1322

1423
interface MountComponentOptions {
1524
readonly overrideProps?: Partial<OdkWebFormsProps>;
25+
readonly onSubmit?: (
26+
payload: MonolithicInstancePayload,
27+
callback: HostSubmissionResultCallback
28+
) => void;
1629
}
1730

1831
const mountComponent = (formXML: string, options?: MountComponentOptions) => {
@@ -22,6 +35,8 @@ const mountComponent = (formXML: string, options?: MountComponentOptions) => {
2235
fetchFormAttachment: () => {
2336
throw new Error('Not exercised here');
2437
},
38+
onSubmit: options?.onSubmit,
39+
2540
...options?.overrideProps,
2641
},
2742
global: globalMountOptions,
@@ -285,4 +300,127 @@ describe('OdkWebForm', () => {
285300
// of `<upload>` controls.
286301
});
287302
});
303+
304+
describe('submission control flow', () => {
305+
const initialInputValue = 'initial input value';
306+
const firstSubmissionInputValue = 'first submission input value';
307+
308+
type AssignedInputValue = typeof firstSubmissionInputValue | typeof initialInputValue;
309+
310+
/**
311+
* @todo As noted in top-level fixture for editing
312+
*/
313+
const resetStateForm = /* xml */ `<?xml version="1.0"?>
314+
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
315+
xmlns:jr="http://openrosa.org/javarosa" xmlns:odk="http://www.opendatakit.org/xforms"
316+
xmlns:orx="http://openrosa.org/xforms">
317+
<h:head>
318+
<h:title>Edit (basic)</h:title>
319+
<model>
320+
<instance>
321+
<data id="edit-basic">
322+
<a>${initialInputValue}</a>
323+
</data>
324+
</instance>
325+
<bind nodeset="/data/a" type="string" />
326+
</model>
327+
</h:head>
328+
<h:body>
329+
<input ref="/data/a" />
330+
</h:body>
331+
</h:html>`;
332+
333+
let submittedPayload: MonolithicInstancePayload | null = null;
334+
let syncResetResult: HostSubmissionResult;
335+
let asyncResetResult: Promise<HostSubmissionResult>;
336+
337+
beforeEach(() => {
338+
submittedPayload = null;
339+
syncResetResult = { next: POST_SUBMIT__NEW_INSTANCE };
340+
asyncResetResult = Promise.resolve(syncResetResult);
341+
});
342+
343+
type HostSubmissionHandler = (
344+
payload: MonolithicInstancePayload
345+
) => OptionalAwaitableHostSubmissionResult;
346+
347+
const postSubmissionResetHandler = (
348+
payload: MonolithicInstancePayload
349+
): HostSubmissionResult => {
350+
submittedPayload = payload;
351+
352+
return syncResetResult;
353+
};
354+
355+
const asyncPostSubmissionResetHandler = (
356+
payload: MonolithicInstancePayload
357+
): Promise<HostSubmissionResult> => {
358+
submittedPayload = payload;
359+
360+
return asyncResetResult;
361+
};
362+
363+
const postSubmissionNoopHandler = (payload: MonolithicInstancePayload) => {
364+
submittedPayload = payload;
365+
};
366+
367+
const asyncPostSubmissionNoopHandler = (payload: MonolithicInstancePayload) => {
368+
submittedPayload = payload;
369+
370+
return Promise.resolve(null);
371+
};
372+
373+
interface SubmissionHandlerCase {
374+
readonly description: string;
375+
readonly hostSubmissonHandler: HostSubmissionHandler | null;
376+
readonly expectedPostSubmissionValue: AssignedInputValue;
377+
}
378+
379+
it.each<SubmissionHandlerCase>([
380+
{
381+
description: 'resets form state after submission (sync host result)',
382+
hostSubmissonHandler: postSubmissionResetHandler,
383+
expectedPostSubmissionValue: initialInputValue,
384+
},
385+
{
386+
description: 'resets form state after submission (async host result)',
387+
hostSubmissonHandler: asyncPostSubmissionResetHandler,
388+
expectedPostSubmissionValue: initialInputValue,
389+
},
390+
{
391+
description: 'does not reset form state by default (sync callback)',
392+
hostSubmissonHandler: postSubmissionNoopHandler,
393+
expectedPostSubmissionValue: firstSubmissionInputValue,
394+
},
395+
{
396+
description: 'does not reset form state by default (async callback)',
397+
hostSubmissonHandler: asyncPostSubmissionNoopHandler,
398+
expectedPostSubmissionValue: firstSubmissionInputValue,
399+
},
400+
])('$description', async ({ hostSubmissonHandler, expectedPostSubmissionValue }) => {
401+
const component = mountComponent(resetStateForm, {
402+
onSubmit: (payload, callback) => {
403+
callback(hostSubmissonHandler?.(payload));
404+
},
405+
});
406+
407+
await flushPromises();
408+
409+
let textInput = component.get<HTMLInputElement>('input.p-inputtext');
410+
411+
expect(textInput.element.value).toBe(initialInputValue);
412+
413+
await textInput.setValue(firstSubmissionInputValue);
414+
415+
// Click submit
416+
await component.get('button[aria-label="Send"]').trigger('click');
417+
418+
// Check that submission callback was called
419+
expect(submittedPayload).not.toBeNull();
420+
421+
textInput = component.get<HTMLInputElement>('input.p-inputtext');
422+
423+
expect(textInput.element.value).toBe(expectedPostSubmissionValue);
424+
});
425+
});
288426
});

0 commit comments

Comments
 (0)