Skip to content

Commit e7287de

Browse files
Merge pull request #355 from getodk/edit-epic/host-app-integration-edit-create-n
Initial support for editing submissions, resetting form state after submission
2 parents 1f894d1 + 2f0b07b commit e7287de

File tree

12 files changed

+667
-59
lines changed

12 files changed

+667
-59
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

.changeset/silly-candles-battle.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 for editing submissions

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

Lines changed: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,73 @@
11
<script setup lang="ts">
2+
import type { FormStateSuccessResult } from '@/lib/init/FormState.ts';
3+
import { initializeFormState } from '@/lib/init/initializeFormState.ts';
4+
import type { EditInstanceOptions } from '@/lib/init/loadFormState';
5+
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';
211
import type {
312
ChunkedInstancePayload,
413
FetchFormAttachment,
514
MissingResourceBehavior,
615
MonolithicInstancePayload,
7-
RootNode,
816
} from '@getodk/xforms-engine';
9-
import { loadForm } from '@getodk/xforms-engine';
1017
import Button from 'primevue/button';
1118
import Card from 'primevue/card';
1219
import PrimeMessage from 'primevue/message';
1320
import type { ComponentPublicInstance } from 'vue';
14-
import { computed, getCurrentInstance, provide, reactive, ref, watchEffect } from 'vue';
15-
import { FormInitializationError } from '../lib/error/FormInitializationError.ts';
21+
import { computed, getCurrentInstance, provide, ref, watchEffect } from 'vue';
1622
import FormLoadFailureDialog from './Form/FormLoadFailureDialog.vue';
1723
import FormHeader from './FormHeader.vue';
1824
import QuestionList from './QuestionList.vue';
1925
2026
const webFormsVersion = __WEB_FORMS_VERSION__;
2127
22-
interface OdkWebFormsProps {
23-
formXml: string;
24-
fetchFormAttachment: FetchFormAttachment;
25-
missingResourceBehavior?: MissingResourceBehavior;
28+
export interface OdkWebFormsProps {
29+
readonly formXml: string;
30+
readonly fetchFormAttachment: FetchFormAttachment;
31+
readonly missingResourceBehavior?: MissingResourceBehavior;
2632
2733
/**
2834
* Note: this parameter must be set when subscribing to the
2935
* {@link OdkWebFormEmits.submitChunked | submitChunked} event.
3036
*/
31-
submissionMaxSize?: number;
37+
readonly submissionMaxSize?: number;
38+
39+
/**
40+
* If provided by a host application, referenced instance and attachment
41+
* resources will be resolved and loaded for editing.
42+
*/
43+
readonly editInstance?: EditInstanceOptions;
3244
}
3345
3446
const props = defineProps<OdkWebFormsProps>();
3547
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+
3664
// 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`.
3765
type OdkWebFormEmits = {
38-
submit: [submissionPayload: MonolithicInstancePayload];
39-
submitChunked: [submissionPayload: ChunkedInstancePayload];
66+
submit: [submissionPayload: MonolithicInstancePayload, callback: HostSubmissionResultCallback];
67+
submitChunked: [
68+
submissionPayload: ChunkedInstancePayload,
69+
callback: HostSubmissionResultCallback,
70+
];
4071
};
4172
4273
/**
@@ -50,7 +81,7 @@ type OdkWebFormEmits = {
5081
* {@link computed} function body (or any function body), but produces the
5182
* expected value assigned to a top level value as it is here.
5283
*/
53-
const instance = getCurrentInstance();
84+
const componentInstance = getCurrentInstance();
5485
5586
type OdkWebFormEmitsEventType = keyof OdkWebFormEmits;
5687
@@ -69,79 +100,67 @@ type EventKey = `on${Capitalize<OdkWebFormEmitsEventType>}`;
69100
70101
/**
71102
* @see {@link https://mokkapps.de/vue-tips/check-if-component-has-event-listener-attached}
72-
* @see {@link instance}
103+
* @see {@link componentInstance}
73104
* @see {@link EventKey}
74105
*/
75106
const isEmitSubscribed = (eventKey: EventKey): boolean => {
76-
return eventKey in (instance?.vnode.props ?? {});
107+
return eventKey in (componentInstance?.vnode.props ?? {});
77108
};
78109
79-
const emitSubmit = async (root: RootNode) => {
110+
const emitSubmit = async (currentState: FormStateSuccessResult) => {
80111
if (isEmitSubscribed('onSubmit')) {
81-
const payload = await root.prepareInstancePayload({
112+
const payload = await currentState.root.prepareInstancePayload({
82113
payloadType: 'monolithic',
83114
});
115+
const callback = hostSubmissionResultCallbackFactory(currentState);
84116
85-
emit('submit', payload);
117+
emit('submit', payload, callback);
86118
}
87119
};
88120
89-
const emitSubmitChunked = async (root: RootNode) => {
121+
const emitSubmitChunked = async (currentState: FormStateSuccessResult) => {
90122
if (isEmitSubscribed('onSubmitChunked')) {
91123
const maxSize = props.submissionMaxSize;
92124
93125
if (maxSize == null) {
94126
throw new Error('The `submissionMaxSize` prop is required for chunked submissions');
95127
}
96128
97-
const payload = await root.prepareInstancePayload({
129+
const payload = await currentState.root.prepareInstancePayload({
98130
payloadType: 'chunked',
99131
maxSize,
100132
});
133+
const callback = hostSubmissionResultCallbackFactory(currentState);
101134
102-
emit('submitChunked', payload);
135+
emit('submitChunked', payload, callback);
103136
}
104137
};
105138
106139
const emit = defineEmits<OdkWebFormEmits>();
107140
108-
const odkForm = ref<RootNode>();
141+
const state = initializeFormState();
109142
const submitPressed = ref(false);
110-
const initializeFormError = ref<FormInitializationError | null>();
111143
112144
const init = async () => {
113-
const { formXml, fetchFormAttachment, missingResourceBehavior } = props;
114-
115-
const formResult = await loadForm(formXml, {
116-
fetchFormAttachment,
117-
missingResourceBehavior,
145+
state.value = await loadFormState(props.formXml, {
146+
form: {
147+
fetchFormAttachment: props.fetchFormAttachment,
148+
missingResourceBehavior: props.missingResourceBehavior,
149+
},
150+
editInstance: props.editInstance ?? null,
118151
});
119-
120-
if (formResult.status === 'failure') {
121-
initializeFormError.value = FormInitializationError.fromError(formResult.error);
122-
123-
return;
124-
}
125-
126-
try {
127-
const { root } = formResult.createInstance({ stateFactory: reactive });
128-
129-
odkForm.value = root;
130-
} catch (error) {
131-
initializeFormError.value = FormInitializationError.from(error);
132-
}
133152
};
134153
135154
void init();
136155
137-
const handleSubmit = () => {
138-
const root = odkForm.value;
156+
const handleSubmit = (currentState: FormStateSuccessResult) => {
157+
const { root } = currentState;
139158
140-
if (root?.validationState.violations?.length === 0) {
159+
if (root.validationState.violations.length === 0) {
141160
// eslint-disable-next-line @typescript-eslint/no-floating-promises
142-
emitSubmit(root);
161+
emitSubmit(currentState);
143162
// eslint-disable-next-line @typescript-eslint/no-floating-promises
144-
emitSubmitChunked(root);
163+
emitSubmitChunked(currentState);
145164
} else {
146165
submitPressed.value = true;
147166
document.scrollingElement?.scrollTo(0, 0);
@@ -153,7 +172,7 @@ const validationErrorMessagePopover = ref<ComponentPublicInstance | null>(null);
153172
provide('submitPressed', submitPressed);
154173
155174
const validationErrorMessage = computed(() => {
156-
const violationLength = odkForm.value!.validationState.violations.length;
175+
const violationLength = state.value.root?.validationState.violations.length ?? 0;
157176
158177
// TODO: translations
159178
if (violationLength === 0) return '';
@@ -180,21 +199,21 @@ watchEffect(() => {
180199
<div
181200
:class="{
182201
'form-initialization-status': true,
183-
loading: odkForm == null && initializeFormError == null,
184-
error: initializeFormError != null,
185-
ready: odkForm != null,
202+
loading: state.status === 'FORM_STATE_LOADING',
203+
error: state.status === 'FORM_STATE_FAILURE',
204+
ready: state.status === 'FORM_STATE_SUCCESS',
186205
}"
187206
/>
188207

189-
<template v-if="initializeFormError != null">
208+
<template v-if="state.status === 'FORM_STATE_FAILURE'">
190209
<FormLoadFailureDialog
191210
severity="error"
192-
:error="initializeFormError"
211+
:error="state.error"
193212
/>
194213
</template>
195214

196215
<div
197-
v-else-if="odkForm"
216+
v-else-if="state.status === 'FORM_STATE_SUCCESS'"
198217
class="odk-form"
199218
:class="{ 'submit-pressed': submitPressed }"
200219
>
@@ -204,21 +223,20 @@ watchEffect(() => {
204223
{{ validationErrorMessage }}
205224
</PrimeMessage>
206225

207-
<FormHeader :form="odkForm" />
226+
<FormHeader :form="state.root" />
208227

209228
<Card class="questions-card">
210229
<template #content>
211230
<div class="form-questions">
212231
<div class="flex flex-column gap-2">
213-
<QuestionList :nodes="odkForm.currentState.children" />
232+
<QuestionList :nodes="state.root.currentState.children" />
214233
</div>
215234
</div>
216235
</template>
217236
</Card>
218237

219238
<div class="footer flex justify-content-end flex-wrap gap-3">
220-
<!-- maybe current state is in odkForm.state.something -->
221-
<Button label="Send" rounded @click="handleSubmit()" />
239+
<Button label="Send" rounded @click="handleSubmit(state)" />
222240
</div>
223241
</div>
224242

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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type {
2+
AnyFormInstance,
3+
LoadFormSuccessResult,
4+
LoadFormWarningResult,
5+
RootNode,
6+
} from '@getodk/xforms-engine';
7+
import type { FormInitializationError } from '../error/FormInitializationError.ts';
8+
9+
/**
10+
* @todo We should probably export a type for this in the engine, both as a
11+
* convenience and to assign a stable name to the concept (which will probably
12+
* evolve when we address
13+
* {@link https://github.com/getodk/web-forms/issues/202 | error conditions} as
14+
* a holistic concern).
15+
*/
16+
export type InstantiableForm = LoadFormSuccessResult | LoadFormWarningResult;
17+
18+
export type FormStateFailureStatus = 'FORM_STATE_FAILURE';
19+
export type FormStateLoadingStatus = 'FORM_STATE_LOADING';
20+
export type FormStateSuccessStatus = 'FORM_STATE_SUCCESS';
21+
22+
export type FormStateStatus =
23+
| FormStateFailureStatus
24+
| FormStateLoadingStatus
25+
| FormStateSuccessStatus;
26+
27+
interface FormStateResult {
28+
readonly status: FormStateStatus;
29+
readonly error: FormInitializationError | null;
30+
readonly form: InstantiableForm | null;
31+
readonly instance: AnyFormInstance | null;
32+
readonly root: RootNode | null;
33+
}
34+
35+
export interface FormStateFailureResult extends FormStateResult {
36+
readonly status: FormStateFailureStatus;
37+
readonly error: FormInitializationError;
38+
readonly form: null;
39+
readonly instance: null;
40+
readonly root: null;
41+
}
42+
43+
export interface FormStateLoadingResult extends FormStateResult {
44+
readonly status: FormStateLoadingStatus;
45+
readonly error: null;
46+
readonly form: null;
47+
readonly instance: null;
48+
readonly root: null;
49+
}
50+
51+
export interface FormStateSuccessResult extends FormStateResult {
52+
readonly status: FormStateSuccessStatus;
53+
readonly error: null;
54+
readonly form: InstantiableForm;
55+
readonly instance: AnyFormInstance;
56+
readonly root: RootNode;
57+
}
58+
59+
// prettier-ignore
60+
export type FormState =
61+
| FormStateFailureResult
62+
| FormStateLoadingResult
63+
| FormStateSuccessResult;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { FormInstanceConfig } from '@getodk/xforms-engine';
2+
import { reactive } from 'vue';
3+
4+
export const ENGINE_FORM_INSTANCE_CONFIG: FormInstanceConfig = {
5+
stateFactory: reactive,
6+
};

0 commit comments

Comments
 (0)