Skip to content

Commit 7b09c62

Browse files
authoredSep 6, 2024··
Actions: add discriminated union support (#11939)
* feat: discriminated union for form validators * chore: changeset
1 parent 0d50d75 commit 7b09c62

File tree

4 files changed

+131
-2
lines changed

4 files changed

+131
-2
lines changed
 

‎.changeset/mighty-stingrays-press.md

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Adds support for Zod discriminated unions on Action form inputs. This allows forms with different inputs to be submitted to the same action, using a given input to decide which object should be used for validation.
6+
7+
This example accepts either a `create` or `update` form submission, and uses the `type` field to determine which object to validate against.
8+
9+
```ts
10+
import { defineAction } from 'astro:actions';
11+
import { z } from 'astro:schema';
12+
13+
export const server = {
14+
changeUser: defineAction({
15+
accept: 'form',
16+
input: z.discriminatedUnion('type', [
17+
z.object({
18+
type: z.literal('create'),
19+
name: z.string(),
20+
email: z.string().email(),
21+
}),
22+
z.object({
23+
type: z.literal('update'),
24+
id: z.number(),
25+
name: z.string(),
26+
email: z.string().email(),
27+
}),
28+
]),
29+
async handler(input) {
30+
if (input.type === 'create') {
31+
// input is { type: 'create', name: string, email: string }
32+
} else {
33+
// input is { type: 'update', id: number, name: string, email: string }
34+
}
35+
},
36+
}),
37+
}
38+
```
39+
40+
The corresponding `create` and `update` forms may look like this:
41+
42+
```astro
43+
---
44+
import { actions } from 'astro:actions';
45+
---
46+
47+
<!--Create-->
48+
<form action={actions.changeUser} method="POST">
49+
<input type="hidden" name="type" value="create" />
50+
<input type="text" name="name" required />
51+
<input type="email" name="email" required />
52+
<button type="submit">Create User</button>
53+
</form>
54+
55+
<!--Update-->
56+
<form action={actions.changeUser} method="POST">
57+
<input type="hidden" name="type" value="update" />
58+
<input type="hidden" name="id" value="user-123" />
59+
<input type="text" name="name" required />
60+
<input type="email" name="email" required />
61+
<button type="submit">Update User</button>
62+
</form>
63+
```

‎packages/astro/src/actions/runtime/virtual/server.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ function getFormServerHandler<TOutput, TInputSchema extends z.ZodType>(
9292

9393
if (!inputSchema) return await handler(unparsedInput, context);
9494

95-
const baseSchema = unwrapSchemaEffects(inputSchema);
95+
const baseSchema = unwrapBaseObjectSchema(inputSchema, unparsedInput);
9696
const parsed = await inputSchema.safeParseAsync(
9797
baseSchema instanceof z.ZodObject
9898
? formDataToObject(unparsedInput, baseSchema)
@@ -191,7 +191,7 @@ function handleFormDataGet(
191191
return validator instanceof z.ZodNumber ? Number(value) : value;
192192
}
193193

194-
function unwrapSchemaEffects(schema: z.ZodType) {
194+
function unwrapBaseObjectSchema(schema: z.ZodType, unparsedInput: FormData) {
195195
while (schema instanceof z.ZodEffects || schema instanceof z.ZodPipeline) {
196196
if (schema instanceof z.ZodEffects) {
197197
schema = schema._def.schema;
@@ -200,5 +200,15 @@ function unwrapSchemaEffects(schema: z.ZodType) {
200200
schema = schema._def.in;
201201
}
202202
}
203+
if (schema instanceof z.ZodDiscriminatedUnion) {
204+
const typeKey = schema._def.discriminator;
205+
const typeValue = unparsedInput.get(typeKey);
206+
if (typeof typeValue !== 'string') return schema;
207+
208+
const objSchema = schema._def.optionsMap.get(typeValue);
209+
if (!objSchema) return schema;
210+
211+
return objSchema;
212+
}
203213
return schema;
204214
}

‎packages/astro/test/actions.test.js

+33
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,39 @@ describe('Astro Actions', () => {
395395
assert.ok(value.date instanceof Date);
396396
assert.ok(value.set instanceof Set);
397397
});
398+
399+
it('Supports discriminated union for different form fields', async () => {
400+
const formData = new FormData();
401+
formData.set('type', 'first-chunk');
402+
formData.set('alt', 'Cool image');
403+
formData.set('image', new File([''], 'chunk-1.png'));
404+
const reqFirst = new Request('http://example.com/_actions/imageUploadInChunks', {
405+
method: 'POST',
406+
body: formData,
407+
});
408+
409+
const resFirst = await app.render(reqFirst);
410+
assert.equal(resFirst.status, 200);
411+
assert.equal(resFirst.headers.get('Content-Type'), 'application/json+devalue');
412+
const data = devalue.parse(await resFirst.text());
413+
const uploadId = data?.uploadId;
414+
assert.ok(uploadId);
415+
416+
const formDataRest = new FormData();
417+
formDataRest.set('type', 'rest-chunk');
418+
formDataRest.set('uploadId', 'fake');
419+
formDataRest.set('image', new File([''], 'chunk-2.png'));
420+
const reqRest = new Request('http://example.com/_actions/imageUploadInChunks', {
421+
method: 'POST',
422+
body: formDataRest,
423+
});
424+
425+
const resRest = await app.render(reqRest);
426+
assert.equal(resRest.status, 200);
427+
assert.equal(resRest.headers.get('Content-Type'), 'application/json+devalue');
428+
const dataRest = devalue.parse(await resRest.text());
429+
assert.equal('fake', dataRest?.uploadId);
430+
});
398431
});
399432
});
400433

‎packages/astro/test/fixtures/actions/src/actions/index.ts

+23
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ const passwordSchema = z
77
.max(128, 'Password length exceeded. Max 128 chars.');
88

99
export const server = {
10+
imageUploadInChunks: defineAction({
11+
accept: 'form',
12+
input: z.discriminatedUnion('type', [
13+
z.object({
14+
type: z.literal('first-chunk'),
15+
image: z.instanceof(File),
16+
alt: z.string(),
17+
}),
18+
z.object({ type: z.literal('rest-chunk'), image: z.instanceof(File), uploadId: z.string() }),
19+
]),
20+
handler: async (data) => {
21+
if (data.type === 'first-chunk') {
22+
const uploadId = Math.random().toString(36).slice(2);
23+
return {
24+
uploadId,
25+
};
26+
} else {
27+
return {
28+
uploadId: data.uploadId,
29+
};
30+
}
31+
},
32+
}),
1033
subscribe: defineAction({
1134
input: z.object({ channel: z.string() }),
1235
handler: async ({ channel }) => {

0 commit comments

Comments
 (0)
Please sign in to comment.