Skip to content

Commit 588290d

Browse files
authored
Merge pull request #12 from bholmesdev/feat/arrays
feat: support arrays
2 parents e3af62e + f0d5019 commit 588290d

File tree

5 files changed

+204
-168
lines changed

5 files changed

+204
-168
lines changed

.vscode/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"editor.defaultFormatter": "biomejs.biome",
3+
"[astro]": {
4+
"editor.defaultFormatter": "astro-build.astro-vscode"
5+
}
6+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
import { createForm } from "simple:form";
3+
import { z } from "zod";
4+
import { ViewTransitions } from "astro:transitions";
5+
6+
const fileUpload = createForm({
7+
hexCodes: z.array(z.string().length(6)),
8+
});
9+
10+
const req = await Astro.locals.form.getData(fileUpload);
11+
12+
if (req?.data) {
13+
console.log(req.data.hexCodes);
14+
}
15+
---
16+
17+
<!doctype html>
18+
<html lang="en">
19+
<head>
20+
<meta charset="UTF-8" />
21+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
22+
<title>Color gradient builder</title>
23+
<ViewTransitions />
24+
</head>
25+
<body>
26+
<form method="POST" class="flex flex-col gap-2">
27+
<template>
28+
<label for="hex-code">Hex code</label>
29+
<input
30+
class="rounded border border-gray-200"
31+
id="hex-code"
32+
{...fileUpload.inputProps.hexCodes}
33+
/>
34+
</template>
35+
<button data-add-color type="button">Add color</button>
36+
{
37+
req?.fieldErrors?.hexCodes?.[0] && (
38+
<p class="text-red-500">{req.fieldErrors.hexCodes[0]}</p>
39+
)
40+
}
41+
<button>Submit</button>
42+
</form>
43+
<style>
44+
form {
45+
display: flex;
46+
flex-direction: column;
47+
gap: 1rem;
48+
}
49+
</style>
50+
51+
<script>
52+
document.addEventListener("astro:page-load", () => {
53+
const form = document.querySelector("form")!;
54+
const button = form.querySelector("button[data-add-color]")!;
55+
const template = form.querySelector("template")!;
56+
57+
const clone = document.importNode(template.content, true);
58+
button.before(clone);
59+
button.addEventListener("click", () => {
60+
console.log("click");
61+
const clone = document.importNode(template.content, true);
62+
button.before(clone);
63+
});
64+
});
65+
</script>
66+
</body>
67+
</html>

packages/form/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,49 @@ signupForm.inputProps;
8989
*/
9090
```
9191

92+
### Handle array values
93+
94+
You may want to submit multiple form values under the same name. This is common for multi-select file inputs, or generated inputs like "add a second contact."
95+
96+
You can aggregate values under the same name using `z.array()` in your validator:
97+
98+
```ts
99+
import { createForm } from "simple:form";
100+
import z from "zod";
101+
102+
const contact = createForm({
103+
contactNames: z.array(z.string()),
104+
});
105+
```
106+
107+
Now, all inputs with the name `contactNames` will be aggregated. This [uses `FormData.getAll()`](https://developer.mozilla.org/en-US/docs/Web/API/FormData/getAll) behind the scenes:
108+
109+
```astro
110+
---
111+
import { createForm } from "simple:form";
112+
import z from "zod";
113+
114+
const contact = createForm({
115+
contactNames: z.array(z.string()),
116+
});
117+
118+
const res = await Astro.locals.form.getData(contact);
119+
console.log(res?.data);
120+
// contactNames: ["Ben", "George"]
121+
---
122+
123+
<form method="POST">
124+
<label for="contact-1">Contact 1</label>
125+
<input id="contact-1" {...contact.inputProps.contactNames} />
126+
{res.fieldErrors?.contactNames?.[0]}
127+
<label for="contact-2">Contact 2</label>
128+
<input id="contact-2" {...contact.inputProps.contactNames} />
129+
{res.fieldErrors?.contactNames?.[1]}
130+
</form>
131+
```
132+
133+
Note that `fieldErrors` can be retrieved by index. For example, to get parse errors for the second input, use `fieldErrors.contactNames[1]`.
134+
92135
### Parse form requests
93136

94137
You can parse form requests from your Astro component frontmatter. Simple form exposes helpers to parse and validate these requests with the [`Astro.locals.form`](https://docs.astro.build/en/reference/api-reference/#astrolocals) object.

packages/form/src/module.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type ZodRawShape,
99
type ZodType,
1010
z,
11+
ZodNullable,
12+
ZodArray,
1113
} from "zod";
1214

1315
export { mapObject };
@@ -52,7 +54,10 @@ export function createForm<T extends ZodRawShape>(validator: T) {
5254
export function getInitialFormState({
5355
validator,
5456
fieldErrors,
55-
}: { validator: FormValidator; fieldErrors: FieldErrors | undefined }) {
57+
}: {
58+
validator: FormValidator;
59+
fieldErrors: FieldErrors | undefined;
60+
}) {
5661
return {
5762
hasFieldErrors: false,
5863
submitStatus: "idle",
@@ -72,24 +77,35 @@ export function getInitialFormState({
7277
function preprocessValidators<T extends ZodRawShape>(formValidator: T) {
7378
return Object.fromEntries(
7479
Object.entries(formValidator).map(([key, validator]) => {
75-
const inputType = getInputType(validator);
76-
switch (inputType) {
80+
const inputType = getInputInfo(validator);
81+
82+
let value = validator;
83+
84+
switch (inputType.type) {
7785
case "checkbox":
78-
return [key, z.preprocess((value) => value === "on", validator)];
86+
value = z.preprocess((value) => value === "on", validator);
87+
break;
7988
case "number":
80-
return [key, z.preprocess(Number, validator)];
89+
value = z.preprocess(Number, validator);
90+
break;
8191
case "text":
82-
return [
83-
key,
84-
z.preprocess(
85-
// Consider empty input as "required"
86-
(value) => (value === "" ? undefined : value),
87-
validator,
88-
),
89-
];
90-
default:
91-
return [key, validator];
92+
value = z.preprocess(
93+
// Consider empty input as "required"
94+
(value) => (value === "" ? undefined : value),
95+
validator,
96+
);
97+
break;
98+
}
99+
100+
if (inputType.isArray) {
101+
value = z.preprocess((v) => {
102+
// Support validating a single input against an array validator
103+
// Use case: input validation on blur
104+
return Array.isArray(v) ? v : [v];
105+
}, validator);
92106
}
107+
108+
return [key, value];
93109
}),
94110
) as T;
95111
}
@@ -187,37 +203,63 @@ function getInputProp<T extends ZodType>(name: string, fieldValidator: T) {
187203
name,
188204
"aria-required":
189205
!fieldValidator.isOptional() && !fieldValidator.isNullable(),
190-
type: getInputType<T>(fieldValidator),
206+
type: getInputInfo<T>(fieldValidator).type,
191207
};
192208

193209
return inputProp;
194210
}
195211

196-
function getInputType<T extends ZodType>(fieldValidator: T): InputProp["type"] {
197-
const resolvedType =
198-
fieldValidator instanceof ZodOptional
199-
? fieldValidator._def.innerType
200-
: fieldValidator;
212+
function getInputInfo<T extends ZodType>(fieldValidator: T): {
213+
type: InputProp["type"];
214+
isArray: boolean;
215+
isOptional: boolean;
216+
} {
217+
let resolvedType = fieldValidator;
218+
let isArray = false;
219+
let isOptional = false;
220+
if (
221+
fieldValidator instanceof ZodOptional ||
222+
fieldValidator instanceof ZodNullable
223+
) {
224+
resolvedType = fieldValidator._def.innerType;
225+
isOptional = true;
226+
}
227+
228+
if (fieldValidator instanceof ZodArray) {
229+
resolvedType = fieldValidator._def.type;
230+
isArray = true;
231+
}
232+
233+
// TODO: respect preprocess() wrappers
234+
235+
let type: InputProp["type"];
201236

202237
if (resolvedType instanceof ZodBoolean) {
203-
return "checkbox";
238+
type = "checkbox";
204239
} else if (resolvedType instanceof ZodNumber) {
205-
return "number";
240+
type = "number";
206241
} else {
207-
return "text";
242+
type = "text";
208243
}
244+
245+
return { type, isArray, isOptional };
209246
}
210247

211248
export async function validateForm<T extends ZodRawShape>({
212249
formData,
213250
validator,
214-
}: { formData: FormData; validator: T }) {
251+
}: {
252+
formData: FormData;
253+
validator: T;
254+
}) {
215255
const result = await z
216256
.preprocess((formData) => {
217257
if (!(formData instanceof FormData)) return formData;
218258

219-
// TODO: handle multiple inputs with same key
220-
return Object.fromEntries(formData);
259+
return mapObject(Object.fromEntries(formData), (key, value) => {
260+
const all = formData.getAll(key);
261+
return all.length > 1 ? all : value;
262+
});
221263
}, z.object(validator))
222264
.safeParseAsync(formData);
223265

0 commit comments

Comments
 (0)