Skip to content

Commit cbfe1d3

Browse files
Merge pull request #349 from getodk/edit-epic/edit-instance-interface
[Edits] Engine I/O support for editing submitted instances
2 parents 5a9dbb3 + 87523b7 commit cbfe1d3

File tree

22 files changed

+595
-176
lines changed

22 files changed

+595
-176
lines changed

.changeset/violet-apricots-collect.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@getodk/xforms-engine': minor
3+
---
4+
5+
Partial support for editing submitted instances:
6+
7+
- Introduce `editInstance` entrypoints, intended for editing previously submitted instance state
8+
- Implement resource resolution for `editInstance` entrypoints, intended for supporting I/O-bound submission edit workflows

packages/common/src/fixtures/import-glob-helper.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1+
import type { Awaitable } from '../../types/helpers.d.ts';
12
import { IS_NODE_RUNTIME } from '../env/detection.ts';
23

34
interface GlobURLFetchResponse {
45
text(): Promise<string>;
56
}
67

7-
type Awaitable<T> = Promise<T> | T;
8-
98
type FetchGlobURL = (globURL: string) => Awaitable<GlobURLFetchResponse>;
109

1110
let fetchGlobURL: FetchGlobURL;

packages/common/types/helpers.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ export type AnyConstructor = ConstructorOf<any>;
4343

4444
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4545
export type AnyFunction = (this: any, ...args: any[]) => any;
46+
47+
export type Awaitable<T> = Promise<T> | T;
48+
49+
export type Thunk<T> = () => T;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts';
2+
import type {
3+
EditedFormInstance,
4+
InstancePayload,
5+
ResolvableFormInstanceInput,
6+
ResolvableInstanceAttachmentsMap,
7+
ResolveFormInstanceResource,
8+
RootNode,
9+
} from '@getodk/xforms-engine';
10+
import { constants as ENGINE_CONSTANTS } from '@getodk/xforms-engine';
11+
import { assert, expect } from 'vitest';
12+
import type { InitializableForm } from './init.ts';
13+
14+
/**
15+
* @todo This type should probably:
16+
*
17+
* - Be exported from the engine
18+
* - With a name like this
19+
* - With the `status` enum updated to replace "ready" with "submittable"
20+
*
21+
* Doing so is deferred for now, to avoid a late breaking change to the engine's
22+
* client interface which will also affect integrating host applications (e.g.
23+
* Central, whose release is blocked awaiting edit functionality).
24+
*
25+
* Similarly, exporting a type of the same name with the existing "ready" enum
26+
* value is deferred for now to avoid a confusing mismatch between names.
27+
*/
28+
type SubmittableInstancePayload = Extract<
29+
InstancePayload<'monolithic'>,
30+
{ readonly status: 'ready' }
31+
>;
32+
33+
type AssertSubmittable = (
34+
payload: InstancePayload<'monolithic'>
35+
) => asserts payload is SubmittableInstancePayload;
36+
37+
/**
38+
* @todo Can Vitest assertion extensions do this type refinement directly?
39+
* Normally we'd use {@link assert} for this, but we already have
40+
* `toBeReadyForSubmission`, with much clearer intent and semantics.
41+
*/
42+
const assertSubmittable: AssertSubmittable = (payload) => {
43+
expect(payload).toBeReadyForSubmission();
44+
};
45+
46+
const mockSubmissionIO = (payload: SubmittableInstancePayload): ResolvableFormInstanceInput => {
47+
const instanceFile = payload.data[0].get(ENGINE_CONSTANTS.INSTANCE_FILE_NAME);
48+
const resolveInstance = () => getBlobText(instanceFile);
49+
const attachmentFiles = Array.from(payload.data)
50+
.flatMap((data) => Array.from(data.values()))
51+
.filter((value): value is File => value !== instanceFile && value instanceof File);
52+
const attachments: ResolvableInstanceAttachmentsMap = new Map(
53+
attachmentFiles.map((file) => {
54+
const resolveAttachment: ResolveFormInstanceResource = () => {
55+
return Promise.resolve(new Response(file));
56+
};
57+
58+
return [file.name, resolveAttachment];
59+
})
60+
);
61+
62+
return {
63+
inputType: 'FORM_INSTANCE_INPUT_RESOLVABLE',
64+
resolveInstance,
65+
attachments,
66+
};
67+
};
68+
69+
/**
70+
* Creates a new {@link EditedFormInstance} from an existing
71+
* {@link instanceRoot}:
72+
*
73+
* 1. Prepare an {@link InstancePayload | instance payload} from the existing
74+
* instance
75+
* 2. Assert that the payload is
76+
* {@link SubmittableInstancePayload | submittable}
77+
* 3. Wrap the payload's data to satisfy the {@link ResolvableFormInstanceInput}
78+
* interface (effectively {@link mockSubmissionIO | mocking submission I/O})
79+
* 4. Create an {@link EditedFormInstance} from that I/O-mocked input
80+
*/
81+
export const editInstance = async (
82+
form: InitializableForm,
83+
instanceRoot: RootNode
84+
): Promise<EditedFormInstance> => {
85+
const payload = await instanceRoot.prepareInstancePayload();
86+
87+
assertSubmittable(payload);
88+
89+
return form.editInstance(mockSubmissionIO(payload));
90+
};

packages/scenario/src/client/init.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type InitializableForm =
3737
| LoadFormWarningResult;
3838

3939
interface InitializedTestForm {
40-
readonly formResult: InitializableForm;
40+
readonly form: InitializableForm;
4141
readonly instanceRoot: RootNode;
4242
readonly owner: Owner;
4343
readonly dispose: VoidFunction;
@@ -50,7 +50,7 @@ export const initializeTestForm = async (
5050
return createRoot(async (dispose) => {
5151
const owner = getAssertedOwner();
5252

53-
const { formResult, root: instanceRoot } = await runInSolidScope(owner, async () => {
53+
const { formResult: form, root: instanceRoot } = await runInSolidScope(owner, async () => {
5454
return createInstance(formResource, {
5555
form: {
5656
...defaultConfig,
@@ -64,7 +64,7 @@ export const initializeTestForm = async (
6464
});
6565

6666
return {
67-
formResult,
67+
form,
6868
instanceRoot,
6969
owner,
7070
dispose,

packages/scenario/src/jr/Scenario.ts

Lines changed: 57 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts';
22
import { xmlElement } from '@getodk/common/test/fixtures/xform-dsl/index.ts';
33
import type {
4+
AnyFormInstance,
45
AnyNode,
56
FormResource,
67
MonolithicInstancePayload,
@@ -19,6 +20,7 @@ import { RankValuesAnswer } from '../answer/RankValuesAnswer.ts';
1920
import { SelectValuesAnswer } from '../answer/SelectValuesAnswer.ts';
2021
import type { ValueNodeAnswer } from '../answer/ValueNodeAnswer.ts';
2122
import { answerOf } from '../client/answerOf.ts';
23+
import { editInstance } from '../client/editInstance.ts';
2224
import type { InitializableForm, TestFormOptions } from '../client/init.ts';
2325
import { initializeTestForm } from '../client/init.ts';
2426
import { isRepeatRange } from '../client/predicates.ts';
@@ -67,14 +69,15 @@ import { JRTreeReference } from './xpath/JRTreeReference.ts';
6769
*/
6870
const nonReactiveIdentityStateFactory = <T extends object>(value: T): T => value;
6971

70-
export interface ScenarioConstructorOptions {
71-
readonly owner: Owner;
72-
readonly dispose: VoidFunction;
72+
interface ScenarioFormMeta {
7373
readonly formName: string;
7474
readonly formElement: XFormsElement;
7575
readonly formOptions: TestFormOptions;
76-
readonly formResult: InitializableForm;
77-
readonly instanceRoot: RootNode;
76+
}
77+
78+
export interface ScenarioConfig extends ScenarioFormMeta {
79+
readonly owner: Owner;
80+
readonly dispose: VoidFunction;
7881
}
7982

8083
type FormFileName = `${string}.xml`;
@@ -128,7 +131,7 @@ const isAnswerItemCollectionParams = (
128131
type ScenarioClass = typeof Scenario;
129132

130133
export interface ScenarioConstructor<T extends Scenario = Scenario> extends ScenarioClass {
131-
new (options: ScenarioConstructorOptions): T;
134+
new (meta: ScenarioConfig, form: InitializableForm, instanceRoot: RootNode): T;
132135
}
133136

134137
/**
@@ -171,75 +174,60 @@ export class Scenario {
171174
this: This,
172175
...args: ScenarioStaticInitParameters
173176
): Promise<This['prototype']> {
174-
let formElement: XFormsElement;
175-
let formName: string;
176-
let formOptions: TestFormOptions;
177+
let formMeta: ScenarioFormMeta;
177178

178179
if (isFormFileName(args[0])) {
179180
return this.init(r(args[0]));
180181
} else if (args.length === 1) {
181182
const [resource] = args;
182183

183-
formElement = xmlElement(resource.textContents);
184-
formName = resource.formName;
185-
formOptions = this.getTestFormOptions();
184+
formMeta = {
185+
formElement: xmlElement(resource.textContents),
186+
formName: resource.formName,
187+
formOptions: this.getTestFormOptions(),
188+
};
186189
} else {
187-
const [name, form, overrideOptions] = args;
190+
const [formName, formElement, overrideOptions] = args;
188191

189-
formName = name;
190-
formElement = form;
191-
formOptions = this.getTestFormOptions(overrideOptions);
192+
formMeta = {
193+
formName,
194+
formElement,
195+
formOptions: this.getTestFormOptions(overrideOptions),
196+
};
192197
}
193198

194-
const formResource = formElement.asXml() satisfies FormResource;
195-
const { dispose, owner, formResult, instanceRoot } = await initializeTestForm(
196-
formResource,
197-
formOptions
199+
const { dispose, owner, form, instanceRoot } = await initializeTestForm(
200+
formMeta.formElement.asXml() satisfies FormResource,
201+
formMeta.formOptions
198202
);
199203

200204
return runInSolidScope(owner, () => {
201-
return new this({
202-
owner,
203-
dispose,
204-
formName,
205-
formElement,
206-
formOptions,
207-
formResult,
208-
instanceRoot,
209-
});
205+
return new this(
206+
{
207+
...formMeta,
208+
owner,
209+
dispose,
210+
},
211+
form,
212+
instanceRoot
213+
);
210214
});
211215
}
212216

213217
declare readonly ['constructor']: ScenarioConstructor<this>;
214218

215-
private readonly owner: Owner;
216-
private readonly dispose: VoidFunction;
217-
private readonly formElement: XFormsElement;
218-
private readonly formOptions: TestFormOptions;
219-
private readonly formResult: InitializableForm;
220-
221-
readonly formName: string;
222-
readonly instanceRoot: RootNode;
223-
224219
protected readonly getPositionalEvents: Accessor<PositionalEvents>;
225220

226221
protected readonly getEventPosition: Accessor<number>;
227222
private readonly setEventPosition: Setter<number>;
228223

229224
protected readonly getSelectedPositionalEvent: Accessor<AnyPositionalEvent>;
230225

231-
protected constructor(options: ScenarioConstructorOptions) {
232-
const { owner, dispose, formName, formElement, formOptions, formResult, instanceRoot } =
233-
options;
234-
235-
this.owner = owner;
236-
this.dispose = dispose;
237-
this.formName = formName;
238-
this.formElement = formElement;
239-
this.formOptions = formOptions;
240-
this.formResult = formResult;
241-
this.instanceRoot = instanceRoot;
242-
226+
protected constructor(
227+
private readonly config: ScenarioConfig,
228+
private readonly form: InitializableForm,
229+
readonly instanceRoot: RootNode
230+
) {
243231
const [getEventPosition, setEventPosition] = createSignal(0);
244232

245233
this.getPositionalEvents = () => getPositionalEvents(instanceRoot);
@@ -260,7 +248,7 @@ export class Scenario {
260248

261249
afterEach(() => {
262250
PositionalEvent.cleanup();
263-
dispose();
251+
config.dispose();
264252
});
265253
}
266254

@@ -768,21 +756,7 @@ export class Scenario {
768756
* will remain) unaffected by those calls.
769757
*/
770758
newInstance(): this {
771-
return runInSolidScope(this.owner, () => {
772-
const { dispose, owner, formName, formElement, formOptions, formResult } = this;
773-
const instance = formResult.createInstance();
774-
const instanceRoot = instance.root;
775-
776-
return new this.constructor({
777-
owner,
778-
dispose,
779-
formName,
780-
formElement,
781-
formOptions,
782-
formResult,
783-
instanceRoot,
784-
});
785-
});
759+
return this.fork(this.form.createInstance());
786760
}
787761

788762
getValidationOutcome(): ValidateOutcome {
@@ -1086,28 +1060,18 @@ export class Scenario {
10861060
}
10871061

10881062
/**
1089-
* @todo We may also want a conceptually equivalent static method, composing
1090-
* `loadForm`/`restoreInstance` behavior.
1063+
* @todo Naming? The name here was chosen to indicate this creates a "fork" of various aspects of a {@link Scenario} instance (most of which are internal/class-private) with a new {@link RootNode | form instance root} (derived from the current {@link Scenario} instance's {@link })
10911064
*/
1092-
async restoreWebFormsInstanceState(payload: RestoreFormInstanceInput): Promise<this> {
1093-
const { dispose, owner, formName, formElement, formOptions, formResult } = this;
1094-
1095-
const instance = await runInSolidScope(owner, () => {
1096-
return this.formResult.restoreInstance(payload, formOptions);
1065+
private fork(instance: AnyFormInstance): this {
1066+
return runInSolidScope(this.config.owner, () => {
1067+
return new this.constructor(this.config, this.form, instance.root);
10971068
});
1098-
const instanceRoot = instance.root;
1069+
}
10991070

1100-
return runInSolidScope(owner, () => {
1101-
return new this.constructor({
1102-
owner,
1103-
dispose,
1104-
formName,
1105-
formElement,
1106-
formOptions,
1107-
formResult,
1108-
instanceRoot,
1109-
});
1110-
});
1071+
async restoreWebFormsInstanceState(payload: RestoreFormInstanceInput): Promise<this> {
1072+
const instance = await this.form.restoreInstance(payload, this.config.formOptions);
1073+
1074+
return this.fork(instance);
11111075
}
11121076

11131077
// TODO: consider adapting tests which use the following interfaces to use
@@ -1186,7 +1150,7 @@ export class Scenario {
11861150
expect(
11871151
form.asXml(),
11881152
'Attempted to serialize instance with unexpected form XML. Is instance from an unrelated form?'
1189-
).toBe(this.formElement.asXml());
1153+
).toBe(this.config.formElement.asXml());
11901154

11911155
return this.proposed_serializeAndRestoreInstanceState();
11921156
}
@@ -1214,6 +1178,13 @@ export class Scenario {
12141178

12151179
return this.restoreWebFormsInstanceState(payload);
12161180
}
1181+
1182+
/** @see {@link editInstance} */
1183+
async proposed_editCurrentInstanceState(): Promise<this> {
1184+
const instance = await editInstance(this.form, this.instanceRoot);
1185+
1186+
return this.fork(instance);
1187+
}
12171188
}
12181189

12191190
/**

0 commit comments

Comments
 (0)