Skip to content

Commit af85549

Browse files
committed
Schema transformations now works with immediate inputs.
Checkboxes, radio buttons, etc. Fixes #298
1 parent 12de039 commit af85549

File tree

4 files changed

+112
-69
lines changed

4 files changed

+112
-69
lines changed

src/lib/client/clientValidation.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,16 @@ export async function validateObjectErrors<T extends AnyZodObject, M>(
281281
});
282282
return currentErrors;
283283
});
284+
284285
// Disable if form values shouldn't be updated immediately:
285-
if (result.data) Form.set(result.data);
286+
//if (result.data) Form.set(result.data);
286287
}
287288
}
288289

289-
type ValidationResult<T extends ZodValidation<AnyZodObject>> = {
290+
export type ValidationResult<T extends Record<string, unknown>> = {
290291
validated: boolean | 'all';
291292
errors: string[] | undefined;
292-
data: z.infer<T> | undefined;
293+
data: T | undefined;
293294
};
294295

295296
/**
@@ -306,7 +307,7 @@ export async function validateField<
306307
Errors: SuperForm<T, M>['errors'],
307308
Tainted: SuperForm<T, M>['tainted'],
308309
options: ValidateOptions<unknown, UnwrapEffects<T>> = {}
309-
): Promise<ValidationResult<T>> {
310+
): Promise<ValidationResult<z.infer<T>>> {
310311
function Errors_clear() {
311312
clearErrors(Errors, { undefinePath: path, clearFormLevelErrors: true });
312313
}
@@ -371,8 +372,6 @@ export async function validateField<
371372
result.errors = Errors_update(result.errors);
372373
}
373374

374-
if (result.data) data.set(result.data, options);
375-
376375
return result;
377376
}
378377

@@ -384,7 +383,7 @@ async function _validateField<T extends ZodValidation<AnyZodObject>, M>(
384383
Errors: SuperForm<T, M>['errors'],
385384
Tainted: SuperForm<T, M>['tainted'],
386385
options: ValidateOptions<unknown, UnwrapEffects<T>> = {}
387-
): Promise<ValidationResult<T>> {
386+
): Promise<ValidationResult<z.infer<T>>> {
388387
if (options.update === undefined) options.update = true;
389388
if (options.taint === undefined) options.taint = false;
390389
if (typeof options.errors == 'string') options.errors = [options.errors];

src/lib/client/formEnhance.ts

Lines changed: 72 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -131,27 +131,37 @@ export function formEnhance<T extends AnyZodObject, M>(
131131
const errors = errs as SuperForm<T, M>['errors'];
132132

133133
async function validateChange(
134-
change: string[],
134+
validityEl: HTMLElement,
135135
event: 'blur' | 'input',
136-
validityEl: HTMLElement | null
136+
errors: string[] | undefined
137137
) {
138+
if (!options.customValidity) return;
138139
if (options.validationMethod == 'submit-only') return;
139140

140-
if (options.customValidity && validityEl) {
141-
// Always reset validity, in case it has been validated on the server.
142-
if ('setCustomValidity' in validityEl) {
143-
(validityEl as HTMLInputElement).setCustomValidity('');
144-
}
141+
// Always reset validity, in case it has been validated on the server.
142+
if ('setCustomValidity' in validityEl) {
143+
(validityEl as HTMLInputElement).setCustomValidity('');
144+
}
145145

146-
if (event == 'input' && options.validationMethod == 'onblur') return;
146+
if (event == 'input' && options.validationMethod == 'onblur') return;
147147

148-
// If event is input but element shouldn't use custom validity,
149-
// return immediately since validateField don't have to be called
150-
// in this case, validation is happening elsewhere.
151-
if (noCustomValidityDataAttribute in validityEl.dataset)
152-
if (event == 'input') return;
153-
else validityEl = null;
154-
}
148+
// If event is input but element shouldn't use custom validity,
149+
// return immediately since validateField don't have to be called
150+
// in this case, validation is happening elsewhere.
151+
if (noCustomValidityDataAttribute in validityEl.dataset) return;
152+
153+
setCustomValidity(validityEl as HTMLInputElement, errors);
154+
}
155+
156+
// Called upon an event from a HTML element that affects the form.
157+
async function htmlInputChange(
158+
change: string[],
159+
event: 'blur' | 'input',
160+
target: HTMLElement | null
161+
) {
162+
if (options.validationMethod == 'submit-only') return;
163+
164+
console.log('htmlInputChange', change, event, target);
155165

156166
const result = await validateField(
157167
change,
@@ -161,25 +171,33 @@ export function formEnhance<T extends AnyZodObject, M>(
161171
tainted
162172
);
163173

164-
if (validityEl) {
165-
setCustomValidity(validityEl as HTMLInputElement, result.errors);
166-
}
174+
// Update data if target exists (immediate is set, refactor please)
175+
if (result.data && target) data.set(result.data);
167176

168-
// NOTE: Uncomment if Zod transformations should be immediately applied, not just when submitting.
169-
// Not enabled because it's not great UX, and it's rare to have transforms, which will just result in
170-
// redundant store updates.
171-
//if (result.data) data.set(result.data);
177+
if (options.customValidity) {
178+
const name = CSS.escape(mergePath(change));
179+
const el = formEl.querySelector<HTMLElement>(`[name="${name}"]`);
180+
if (el) validateChange(el, event, result.errors);
181+
}
172182
}
173183

184+
const immediateInputTypes = [
185+
'button',
186+
'checkbox',
187+
'radio',
188+
'range',
189+
'submit'
190+
];
191+
174192
/**
175193
* Some input fields have timing issues with the stores, need to wait in that case.
176194
*/
177-
function timingIssue(el: EventTarget | null) {
195+
function immediateInput(el: EventTarget | null) {
178196
return (
179197
el &&
180198
(el instanceof HTMLSelectElement ||
181199
(el instanceof HTMLInputElement &&
182-
(el.type == 'radio' || el.type == 'checkbox')))
200+
immediateInputTypes.includes(el.type)))
183201
);
184202
}
185203

@@ -192,61 +210,66 @@ export function formEnhance<T extends AnyZodObject, M>(
192210
return;
193211
}
194212

195-
if (timingIssue(e.target)) {
213+
const target = e.target instanceof HTMLElement ? e.target : null;
214+
const immediateUpdate = immediateInput(target);
215+
216+
// Immediate inputs has a timing issue and needs to be waited for
217+
if (immediateUpdate) {
196218
await new Promise((r) => setTimeout(r, 0));
197219
}
198220

199221
for (const change of get(lastChanges)) {
200-
let validityEl: HTMLElement | null = null;
201-
202-
if (options.customValidity) {
203-
const name = CSS.escape(mergePath(change));
204-
validityEl = formEl.querySelector<HTMLElement>(`[name="${name}"]`);
205-
}
206-
207-
validateChange(change, 'blur', validityEl);
222+
htmlInputChange(change, 'blur', immediateUpdate ? null : target);
208223
}
209224
// Clear last changes after blur (not after input)
210225
lastChanges.set([]);
211226
}
212227
formEl.addEventListener('focusout', checkBlur);
213228

214229
// Add input event, for custom validity
215-
async function checkCustomValidity(e: Event) {
230+
async function checkInput(e: Event) {
216231
if (
217232
options.validationMethod == 'onblur' ||
218233
options.validationMethod == 'submit-only'
219234
) {
220235
return;
221236
}
222237

223-
if (timingIssue(e.target)) {
238+
const immediateUpdate = immediateInput(e.target);
239+
240+
if (immediateUpdate) {
224241
await new Promise((r) => setTimeout(r, 0));
225242
}
226243

227-
for (const change of get(lastChanges)) {
228-
const name = CSS.escape(mergePath(change));
229-
const validityEl = formEl.querySelector<HTMLElement>(
230-
`[name="${name}"]`
231-
);
232-
if (!validityEl) continue;
244+
const target = e.target instanceof HTMLElement ? e.target : null;
233245

234-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
235-
const hadErrors = traversePath(get(errors), change as any);
236-
if (hadErrors && hadErrors.key in hadErrors.parent) {
246+
for (const change of get(lastChanges)) {
247+
const hadErrors =
248+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
249+
immediateUpdate || traversePath(get(errors), change as any);
250+
if (
251+
immediateUpdate ||
252+
(typeof hadErrors == 'object' && hadErrors.key in hadErrors.parent)
253+
) {
237254
// Problem - store hasn't updated here with new value yet.
238-
setTimeout(() => validateChange(change, 'input', validityEl), 0);
255+
setTimeout(
256+
() =>
257+
htmlInputChange(
258+
change,
259+
'input',
260+
immediateUpdate ? target : null
261+
),
262+
0
263+
);
239264
}
240265
}
241266
}
242267

243-
if (options.customValidity) {
244-
formEl.addEventListener('input', checkCustomValidity);
245-
}
268+
formEl.addEventListener('input', checkInput);
246269

247270
onDestroy(() => {
248271
formEl.removeEventListener('focusout', checkBlur);
249-
formEl.removeEventListener('input', checkCustomValidity);
272+
formEl.removeEventListener('input', checkInput);
250273
});
251274

252275
const htmlForm = Form(formEl, { submitting, delayed, timeout }, options);

src/routes/tests/issue-298/+page.svelte

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,36 @@
44
import { superValidateSync } from '$lib/client';
55
import SuperDebug from '$lib/client/SuperDebug.svelte';
66
7-
export const timePatternSchema = z.object({
8-
recurrenceRuleSets: z
7+
function ruleSet<T extends readonly [string, ...string[]]>(options: T) {
8+
let prev: string | undefined = undefined;
9+
let current = options[0];
10+
return z
911
.object({
10-
endType: z.enum(['never', 'date', 'after']),
11-
rrule: z.string()
12+
options: z.enum(options).default(options[0] as any),
13+
prev: z.string().optional()
1214
})
1315
.transform((value) => {
14-
let newRrule = 'after';
15-
console.log({ ...value, rrule: newRrule });
16-
return { ...value, rrule: newRrule };
17-
})
16+
const output = { ...value, prev: prev };
17+
prev = current;
18+
current = value.options as string;
19+
return output;
20+
});
21+
}
22+
23+
const r1 = ['r1A', 'r1B', 'r1C'] as const;
24+
const r2 = ['r2A', 'r2B', 'r2C'] as const;
25+
26+
const schema = z.object({
27+
r1: ruleSet(r1),
28+
r2: ruleSet(r2)
1829
});
1930
20-
const superForm = _superForm(superValidateSync(timePatternSchema), {
31+
type T = z.infer<typeof schema>;
32+
33+
const superForm = _superForm(superValidateSync(schema), {
2134
SPA: true,
2235
dataType: 'json',
23-
validators: timePatternSchema,
36+
validators: schema,
2437
taintedMessage: null
2538
});
2639
@@ -30,16 +43,24 @@
3043
<SuperDebug data={$form} />
3144

3245
<form use:superForm.enhance method="post">
33-
{#each ['never', 'date', 'after'] as item}
46+
{#each r1 as item}
3447
<div>
3548
<input
3649
value={item}
37-
bind:group={$form.recurrenceRuleSets.endType}
50+
bind:group={$form.r1.options}
3851
type="radio"
3952
id={item}
4053
name={item}
4154
/>
4255
{item}
4356
</div>
4457
{/each}
58+
59+
<hr />
60+
61+
<select bind:value={$form.r2.options}>
62+
{#each r2 as item}
63+
<option value={item}>{item}</option>
64+
{/each}
65+
</select>
4566
</form>

src/routes/tests/spa-schema-transform/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod';
22

33
export const schema = z.object({
4-
name: z.string().min(1),
4+
name: z.string().min(1).trim(),
55
email: z
66
.string()
77
.email()

0 commit comments

Comments
 (0)