@zeno-lib/forms 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-zeno-form.d.mts +18 -9
- package/dist/create-zeno-form.mjs +68 -23
- package/dist/lib/schema-defaults.d.mts +3 -1
- package/dist/lib/schema-defaults.mjs +43 -1
- package/dist/lib/submit-action.mjs +7 -2
- package/package.json +2 -2
- package/src/composition.test.tsx +140 -0
- package/src/create-zeno-form.tsx +237 -44
- package/src/default-props.test.tsx +25 -1
- package/src/form-dialog.test.tsx +34 -0
- package/src/form-error.test.tsx +47 -0
- package/src/lib/schema-defaults.test.ts +128 -1
- package/src/lib/schema-defaults.ts +74 -2
- package/src/lib/submit-action.ts +17 -2
- package/src/submit-action.test.tsx +45 -0
- package/src/use-form.test-d.ts +23 -0
- package/src/use-form.test.tsx +46 -0
- package/src/with-form.test-d.ts +25 -0
- package/src/with-form.test.tsx +2 -11
package/src/create-zeno-form.tsx
CHANGED
|
@@ -3,20 +3,34 @@
|
|
|
3
3
|
|
|
4
4
|
import type {
|
|
5
5
|
AnyFormApi,
|
|
6
|
+
AppFieldExtendedReactFieldGroupApi,
|
|
7
|
+
AppFieldExtendedReactFormApi,
|
|
8
|
+
BaseFormOptions,
|
|
6
9
|
DeepKeys,
|
|
7
10
|
DeepValue,
|
|
11
|
+
FieldsMap,
|
|
8
12
|
FormAsyncValidateOrFn,
|
|
9
13
|
FormOptions,
|
|
10
14
|
FormValidateOrFn,
|
|
15
|
+
WithFormProps,
|
|
11
16
|
} from "@tanstack/react-form"
|
|
12
17
|
import { createFormHook } from "@tanstack/react-form"
|
|
13
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type ComponentProps,
|
|
20
|
+
type PropsWithChildren,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
useMemo,
|
|
23
|
+
} from "react"
|
|
14
24
|
import { applyValidationError } from "./lib/apply-validation-error"
|
|
15
25
|
import { fieldContext, formContext } from "./lib/contexts"
|
|
16
26
|
import { withDefaultProps } from "./lib/default-props"
|
|
17
27
|
import { withEmptyStartValues } from "./lib/empty-value"
|
|
18
28
|
import { useDisabledOption } from "./lib/form-disabled"
|
|
19
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
deepMergeDefaults,
|
|
31
|
+
extractZodDefaults,
|
|
32
|
+
pickSchemaShape,
|
|
33
|
+
} from "./lib/schema-defaults"
|
|
20
34
|
import {
|
|
21
35
|
getRequiredPaths,
|
|
22
36
|
type StandardSchemaLike,
|
|
@@ -390,42 +404,211 @@ export function createZenoForm<
|
|
|
390
404
|
config.formComponents,
|
|
391
405
|
config.defaultProps
|
|
392
406
|
)
|
|
393
|
-
const {
|
|
407
|
+
const {
|
|
408
|
+
useAppForm,
|
|
409
|
+
withFieldGroup: withTanStackFieldGroup,
|
|
410
|
+
withForm: withTanStackForm,
|
|
411
|
+
} = createFormHook({
|
|
394
412
|
fieldComponents,
|
|
395
413
|
fieldContext,
|
|
396
414
|
formComponents,
|
|
397
415
|
formContext,
|
|
398
416
|
})
|
|
399
417
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
418
|
+
// One set of named fields per form, shared by `useForm`, `useAppFields` and
|
|
419
|
+
// every section and group that renders the form.
|
|
420
|
+
const fieldsByForm = new WeakMap<object, Record<string, AnyComponent>>()
|
|
421
|
+
|
|
422
|
+
function getAppFields<TForm extends AnyAppForm>(form: TForm) {
|
|
423
|
+
type Fields = FieldWrappers<FC, FormDataOf<TForm>>
|
|
424
|
+
const cached = fieldsByForm.get(form)
|
|
425
|
+
if (cached !== undefined) {
|
|
426
|
+
return cached as Fields
|
|
427
|
+
}
|
|
428
|
+
const Field = form.AppField as React.ComponentType<{
|
|
429
|
+
name: string
|
|
430
|
+
validators?: AnyFieldValidators
|
|
431
|
+
listeners?: AnyFieldListeners
|
|
432
|
+
children: () => ReactNode
|
|
433
|
+
}>
|
|
434
|
+
const wrappers: Record<string, AnyComponent> = {}
|
|
435
|
+
for (const key of Object.keys(fieldComponents)) {
|
|
436
|
+
const Impl = fieldComponents[key] as AnyComponent
|
|
437
|
+
wrappers[key] = ({
|
|
438
|
+
name,
|
|
439
|
+
validators,
|
|
440
|
+
listeners,
|
|
441
|
+
...props
|
|
442
|
+
}: {
|
|
404
443
|
name: string
|
|
405
444
|
validators?: AnyFieldValidators
|
|
406
445
|
listeners?: AnyFieldListeners
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
446
|
+
}) => (
|
|
447
|
+
<Field listeners={listeners} name={name} validators={validators}>
|
|
448
|
+
{() => <Impl {...props} />}
|
|
449
|
+
</Field>
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
fieldsByForm.set(form, wrappers)
|
|
453
|
+
return wrappers as Fields
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function useAppFields<TForm extends AnyAppForm>(form: TForm) {
|
|
457
|
+
return getAppFields(form)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Puts the named fields on `target` (a section's form, a group) where it has
|
|
461
|
+
// none of that name. The same fields every time, so running it on each
|
|
462
|
+
// render changes nothing after the first.
|
|
463
|
+
function withNamedFields<T extends AnyAppForm>(target: T): T {
|
|
464
|
+
for (const [name, field] of Object.entries(getAppFields(target))) {
|
|
465
|
+
if (!(name in target)) {
|
|
466
|
+
Object.assign(target, { [name]: field })
|
|
426
467
|
}
|
|
427
|
-
|
|
428
|
-
|
|
468
|
+
}
|
|
469
|
+
return target
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* TanStack's `withForm`, whose `render` gets a `form` carrying the typed
|
|
474
|
+
* fields, as `useForm`'s does: `<form.InputField name="email" />`.
|
|
475
|
+
*/
|
|
476
|
+
function withForm<
|
|
477
|
+
TFormData,
|
|
478
|
+
TOnMount extends SyncValidator<TFormData>,
|
|
479
|
+
TOnChange extends SyncValidator<TFormData>,
|
|
480
|
+
TOnChangeAsync extends AsyncValidator<TFormData>,
|
|
481
|
+
TOnBlur extends SyncValidator<TFormData>,
|
|
482
|
+
TOnBlurAsync extends AsyncValidator<TFormData>,
|
|
483
|
+
TOnSubmit extends SyncValidator<TFormData>,
|
|
484
|
+
TOnSubmitAsync extends AsyncValidator<TFormData>,
|
|
485
|
+
TOnDynamic extends SyncValidator<TFormData>,
|
|
486
|
+
TOnDynamicAsync extends AsyncValidator<TFormData>,
|
|
487
|
+
TOnServer extends AsyncValidator<TFormData>,
|
|
488
|
+
TSubmitMeta,
|
|
489
|
+
// biome-ignore lint/complexity/noBannedTypes: TanStack's own default, so a section without `props` infers as it does.
|
|
490
|
+
TRenderProps extends object = {},
|
|
491
|
+
>({
|
|
492
|
+
render,
|
|
493
|
+
...options
|
|
494
|
+
}: Omit<
|
|
495
|
+
WithFormProps<
|
|
496
|
+
TFormData,
|
|
497
|
+
TOnMount,
|
|
498
|
+
TOnChange,
|
|
499
|
+
TOnChangeAsync,
|
|
500
|
+
TOnBlur,
|
|
501
|
+
TOnBlurAsync,
|
|
502
|
+
TOnSubmit,
|
|
503
|
+
TOnSubmitAsync,
|
|
504
|
+
TOnDynamic,
|
|
505
|
+
TOnDynamicAsync,
|
|
506
|
+
TOnServer,
|
|
507
|
+
TSubmitMeta,
|
|
508
|
+
FC,
|
|
509
|
+
FMC,
|
|
510
|
+
TRenderProps
|
|
511
|
+
>,
|
|
512
|
+
"render"
|
|
513
|
+
> & {
|
|
514
|
+
render: (
|
|
515
|
+
props: PropsWithChildren<
|
|
516
|
+
NoInfer<TRenderProps> & {
|
|
517
|
+
// The named fields ride in the form components' slot, so the type
|
|
518
|
+
// stays TanStack's and a nested `withFieldGroup` still infers it.
|
|
519
|
+
form: AppFieldExtendedReactFormApi<
|
|
520
|
+
TFormData,
|
|
521
|
+
TOnMount,
|
|
522
|
+
TOnChange,
|
|
523
|
+
TOnChangeAsync,
|
|
524
|
+
TOnBlur,
|
|
525
|
+
TOnBlurAsync,
|
|
526
|
+
TOnSubmit,
|
|
527
|
+
TOnSubmitAsync,
|
|
528
|
+
TOnDynamic,
|
|
529
|
+
TOnDynamicAsync,
|
|
530
|
+
TOnServer,
|
|
531
|
+
TSubmitMeta,
|
|
532
|
+
FC,
|
|
533
|
+
FMC & FieldWrappers<FC, TFormData>
|
|
534
|
+
>
|
|
535
|
+
}
|
|
536
|
+
>
|
|
537
|
+
) => ReactNode
|
|
538
|
+
}) {
|
|
539
|
+
return withTanStackForm<
|
|
540
|
+
TFormData,
|
|
541
|
+
TOnMount,
|
|
542
|
+
TOnChange,
|
|
543
|
+
TOnChangeAsync,
|
|
544
|
+
TOnBlur,
|
|
545
|
+
TOnBlurAsync,
|
|
546
|
+
TOnSubmit,
|
|
547
|
+
TOnSubmitAsync,
|
|
548
|
+
TOnDynamic,
|
|
549
|
+
TOnDynamicAsync,
|
|
550
|
+
TOnServer,
|
|
551
|
+
TSubmitMeta,
|
|
552
|
+
TRenderProps
|
|
553
|
+
>({
|
|
554
|
+
...options,
|
|
555
|
+
render: (props) =>
|
|
556
|
+
render({
|
|
557
|
+
...props,
|
|
558
|
+
form: withNamedFields(props.form),
|
|
559
|
+
} as Parameters<typeof render>[0]),
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* TanStack's `withFieldGroup`, whose `render` gets a `group` carrying the
|
|
565
|
+
* typed fields of the group's own values, as `useForm`'s form does:
|
|
566
|
+
* `<group.InputField name="street" />`.
|
|
567
|
+
*/
|
|
568
|
+
function withFieldGroup<
|
|
569
|
+
TFieldGroupData,
|
|
570
|
+
TSubmitMeta,
|
|
571
|
+
// biome-ignore lint/complexity/noBannedTypes: TanStack's own default, so a group without `props` infers as it does.
|
|
572
|
+
TRenderProps extends object = {},
|
|
573
|
+
>({
|
|
574
|
+
render,
|
|
575
|
+
...options
|
|
576
|
+
}: BaseFormOptions<TFieldGroupData, TSubmitMeta> & {
|
|
577
|
+
props?: TRenderProps
|
|
578
|
+
render: (
|
|
579
|
+
props: PropsWithChildren<
|
|
580
|
+
NoInfer<TRenderProps> & {
|
|
581
|
+
// As `withForm`'s, with TanStack's own arguments for the rest.
|
|
582
|
+
group: AppFieldExtendedReactFieldGroupApi<
|
|
583
|
+
unknown,
|
|
584
|
+
TFieldGroupData,
|
|
585
|
+
string | FieldsMap<unknown, TFieldGroupData>,
|
|
586
|
+
undefined | FormValidateOrFn<unknown>,
|
|
587
|
+
undefined | FormValidateOrFn<unknown>,
|
|
588
|
+
undefined | FormAsyncValidateOrFn<unknown>,
|
|
589
|
+
undefined | FormValidateOrFn<unknown>,
|
|
590
|
+
undefined | FormAsyncValidateOrFn<unknown>,
|
|
591
|
+
undefined | FormValidateOrFn<unknown>,
|
|
592
|
+
undefined | FormAsyncValidateOrFn<unknown>,
|
|
593
|
+
undefined | FormValidateOrFn<unknown>,
|
|
594
|
+
undefined | FormAsyncValidateOrFn<unknown>,
|
|
595
|
+
undefined | FormAsyncValidateOrFn<unknown>,
|
|
596
|
+
unknown extends TSubmitMeta ? never : TSubmitMeta,
|
|
597
|
+
FC,
|
|
598
|
+
FMC & FieldWrappers<FC, TFieldGroupData>
|
|
599
|
+
>
|
|
600
|
+
}
|
|
601
|
+
>
|
|
602
|
+
) => ReactNode
|
|
603
|
+
}) {
|
|
604
|
+
return withTanStackFieldGroup<TFieldGroupData, TSubmitMeta, TRenderProps>({
|
|
605
|
+
...options,
|
|
606
|
+
render: (props) =>
|
|
607
|
+
render({
|
|
608
|
+
...props,
|
|
609
|
+
group: withNamedFields(props.group),
|
|
610
|
+
} as Parameters<typeof render>[0]),
|
|
611
|
+
})
|
|
429
612
|
}
|
|
430
613
|
|
|
431
614
|
// An unset generic defaults to its constraint, which is what TanStack's own
|
|
@@ -531,7 +714,17 @@ export function createZenoForm<
|
|
|
531
714
|
|
|
532
715
|
const wrappedOnSubmit = userOnSubmit
|
|
533
716
|
? ((async (props: { formApi: AnyFormApi; value: TFormData }) => {
|
|
534
|
-
const {
|
|
717
|
+
const { formApi } = props
|
|
718
|
+
const { submissionAttempts } = formApi.state
|
|
719
|
+
// A reset (a dialog closed meanwhile) or a newer submit while this
|
|
720
|
+
// one runs makes its failure stale, even once a later submit brings
|
|
721
|
+
// the count back. An edit doesn't: the save still didn't happen.
|
|
722
|
+
let superseded = false
|
|
723
|
+
const watch = formApi.store.subscribe(() => {
|
|
724
|
+
if (formApi.state.submissionAttempts !== submissionAttempts) {
|
|
725
|
+
superseded = true
|
|
726
|
+
}
|
|
727
|
+
})
|
|
535
728
|
try {
|
|
536
729
|
await (
|
|
537
730
|
userOnSubmit as unknown as (
|
|
@@ -540,20 +733,15 @@ export function createZenoForm<
|
|
|
540
733
|
)(props)
|
|
541
734
|
} catch (error) {
|
|
542
735
|
if (error instanceof ValidationError) {
|
|
543
|
-
applyValidationError(
|
|
736
|
+
applyValidationError(formApi, error)
|
|
544
737
|
return
|
|
545
738
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// values no longer on screen.
|
|
549
|
-
const { state } = props.formApi
|
|
550
|
-
if (
|
|
551
|
-
state.values === props.value &&
|
|
552
|
-
state.submissionAttempts === submissionAttempts
|
|
553
|
-
) {
|
|
554
|
-
setSubmitError(props.formApi, error)
|
|
739
|
+
if (!superseded) {
|
|
740
|
+
setSubmitError(formApi, error)
|
|
555
741
|
}
|
|
556
742
|
throw error
|
|
743
|
+
} finally {
|
|
744
|
+
watch.unsubscribe()
|
|
557
745
|
}
|
|
558
746
|
}) as typeof userOnSubmit)
|
|
559
747
|
: undefined
|
|
@@ -579,15 +767,21 @@ export function createZenoForm<
|
|
|
579
767
|
[schema]
|
|
580
768
|
)
|
|
581
769
|
|
|
770
|
+
// The caller's values keep only the keys the schema describes, so a
|
|
771
|
+
// record as read (`id`, `createdAt`, …) doesn't fail a `.strict()` schema
|
|
772
|
+
// on submit. The schema's own defaults then fill the keys left out.
|
|
582
773
|
const mergedDefaultValues = useMemo(
|
|
583
774
|
() =>
|
|
584
775
|
schemaDefaults
|
|
585
776
|
? (deepMergeDefaults(
|
|
586
777
|
schemaDefaults,
|
|
587
|
-
|
|
778
|
+
pickSchemaShape(
|
|
779
|
+
schema as Parameters<typeof pickSchemaShape>[0],
|
|
780
|
+
userDefaultValues as Record<string, unknown> | undefined
|
|
781
|
+
)
|
|
588
782
|
) as typeof userDefaultValues)
|
|
589
783
|
: userDefaultValues,
|
|
590
|
-
[schemaDefaults, userDefaultValues]
|
|
784
|
+
[schema, schemaDefaults, userDefaultValues]
|
|
591
785
|
)
|
|
592
786
|
|
|
593
787
|
// Keep `formApi.reset(values)` from being undone by the next render; see
|
|
@@ -632,10 +826,9 @@ export function createZenoForm<
|
|
|
632
826
|
useDisabledOption(form, disabled)
|
|
633
827
|
useUnsavedChangesWarning(form, unsavedChangesWarning)
|
|
634
828
|
|
|
635
|
-
const fields = useAppFields(form)
|
|
636
829
|
return useMemo(
|
|
637
|
-
() => Object.assign(form,
|
|
638
|
-
[form
|
|
830
|
+
() => Object.assign(form, getAppFields(form), formComponents),
|
|
831
|
+
[form]
|
|
639
832
|
)
|
|
640
833
|
}
|
|
641
834
|
|
|
@@ -15,7 +15,7 @@ afterEach(() => {
|
|
|
15
15
|
vi.unstubAllGlobals()
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
const { useForm } = createZenoForm({
|
|
18
|
+
const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
19
19
|
defaultProps: {
|
|
20
20
|
FormError: { submitErrorMessage: "Not saved. Try again later." },
|
|
21
21
|
MoneyField: { currency: "CHF", currencyDisplay: "code", placeholder: "0" },
|
|
@@ -162,4 +162,28 @@ describe("createZenoForm({ defaultProps })", () => {
|
|
|
162
162
|
render(<AppFieldHarness />)
|
|
163
163
|
expect(addonOf("Amount")).toBe("CHF")
|
|
164
164
|
})
|
|
165
|
+
|
|
166
|
+
test("a section's and a group's fields get their defaults too", () => {
|
|
167
|
+
const AmountSection = withForm({
|
|
168
|
+
defaultValues: VALUES,
|
|
169
|
+
render: ({ form }) => <form.MoneyField label="Amount" name="amount" />,
|
|
170
|
+
})
|
|
171
|
+
const FeeGroup = withFieldGroup({
|
|
172
|
+
defaultValues: { fee: VALUES.fee },
|
|
173
|
+
render: ({ group }) => <group.MoneyField label="Fee" name="fee" />,
|
|
174
|
+
})
|
|
175
|
+
function SectionHarness() {
|
|
176
|
+
// `useAppForm`'s form has no named fields until a section renders it.
|
|
177
|
+
const form = useAppForm({ defaultValues: VALUES, onSubmit: vi.fn() })
|
|
178
|
+
return (
|
|
179
|
+
<FormProvider form={form}>
|
|
180
|
+
<AmountSection form={form} />
|
|
181
|
+
<FeeGroup fields={{ fee: "fee" }} form={form} />
|
|
182
|
+
</FormProvider>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
render(<SectionHarness />)
|
|
186
|
+
expect(addonOf("Amount")).toBe("CHF")
|
|
187
|
+
expect(addonOf("Fee")).toBe("CHF")
|
|
188
|
+
})
|
|
165
189
|
})
|
package/src/form-dialog.test.tsx
CHANGED
|
@@ -178,6 +178,40 @@ describe("FormDialog", () => {
|
|
|
178
178
|
expect(name.value).toBe("Ada")
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
|
|
182
|
+
const user = userEvent.setup()
|
|
183
|
+
const strictSchema = schema.strict()
|
|
184
|
+
const record = { ...ROW, createdAt: "2026-01-01", id: 7 }
|
|
185
|
+
const onSubmit = vi.fn()
|
|
186
|
+
function StrictHarness() {
|
|
187
|
+
const dialog = useFormDialog<Values>({ defaultValues: BLANK })
|
|
188
|
+
const form = useForm({
|
|
189
|
+
defaultValues: dialog.defaultValues,
|
|
190
|
+
onSubmit: ({ value }) => onSubmit(value),
|
|
191
|
+
schema: strictSchema,
|
|
192
|
+
})
|
|
193
|
+
const { InputField } = form
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => dialog.open({ defaultValues: record })}
|
|
198
|
+
type="button"
|
|
199
|
+
>
|
|
200
|
+
Edit
|
|
201
|
+
</button>
|
|
202
|
+
<FormDialog dialog={dialog} form={form} title="Person">
|
|
203
|
+
<InputField label="Name" name="name" />
|
|
204
|
+
</FormDialog>
|
|
205
|
+
</>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
render(<StrictHarness />)
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "Edit" }))
|
|
210
|
+
await screen.findByLabelText(NAME)
|
|
211
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
212
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(ROW))
|
|
213
|
+
})
|
|
214
|
+
|
|
181
215
|
test("closes without asking when nothing changed", async () => {
|
|
182
216
|
const user = userEvent.setup()
|
|
183
217
|
render(<Harness />)
|
package/src/form-error.test.tsx
CHANGED
|
@@ -136,6 +136,53 @@ describe("<FormError> with <Form>", () => {
|
|
|
136
136
|
expect(screen.queryByRole("alert")).toBeNull()
|
|
137
137
|
})
|
|
138
138
|
|
|
139
|
+
test("a submit that throws after an edit made while it ran still shows", async () => {
|
|
140
|
+
const user = userEvent.setup()
|
|
141
|
+
let rejectSubmit: (error: Error) => void = () => undefined
|
|
142
|
+
const onSubmit = vi.fn(
|
|
143
|
+
() =>
|
|
144
|
+
new Promise((_, reject) => {
|
|
145
|
+
rejectSubmit = reject
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
149
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
150
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
151
|
+
|
|
152
|
+
await user.type(screen.getByLabelText(NAME), "!")
|
|
153
|
+
rejectSubmit(new Error("offline"))
|
|
154
|
+
expect(await screen.findByRole("alert")).toBeTruthy()
|
|
155
|
+
|
|
156
|
+
// The next edit clears it, as after any thrown submit.
|
|
157
|
+
await user.type(screen.getByLabelText(NAME), "?")
|
|
158
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("a reset while a submit runs drops its error, even after a new submit", async () => {
|
|
162
|
+
const user = userEvent.setup()
|
|
163
|
+
let rejectFirst: (error: Error) => void = () => undefined
|
|
164
|
+
const onSubmit = vi
|
|
165
|
+
.fn<(values: Values) => unknown>()
|
|
166
|
+
.mockImplementationOnce(
|
|
167
|
+
() =>
|
|
168
|
+
new Promise((_, reject) => {
|
|
169
|
+
rejectFirst = reject
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
.mockImplementationOnce(() => new Promise(() => undefined))
|
|
173
|
+
render(<Profile onSubmit={onSubmit} />)
|
|
174
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
175
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
|
176
|
+
|
|
177
|
+
await user.click(screen.getByRole("button", { name: "Reset" }))
|
|
178
|
+
// Back to the first submit's attempt count, still running.
|
|
179
|
+
await user.click(screen.getByRole("button", SUBMIT))
|
|
180
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2))
|
|
181
|
+
rejectFirst(new Error("offline"))
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
183
|
+
expect(screen.queryByRole("alert")).toBeNull()
|
|
184
|
+
})
|
|
185
|
+
|
|
139
186
|
test("a reset drops a thrown submit's error, even when it restores the same values", async () => {
|
|
140
187
|
const user = userEvent.setup()
|
|
141
188
|
let resolveRetry: () => void = () => undefined
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest"
|
|
2
2
|
import { z } from "zod"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
deepMergeDefaults,
|
|
5
|
+
extractZodDefaults,
|
|
6
|
+
pickSchemaShape,
|
|
7
|
+
} from "./schema-defaults"
|
|
4
8
|
|
|
5
9
|
describe("extractZodDefaults — primitives", () => {
|
|
6
10
|
test("z.string() → ''", () => {
|
|
@@ -192,3 +196,126 @@ describe("deepMergeDefaults", () => {
|
|
|
192
196
|
expect(deepMergeDefaults({ x: { a: 1 } }, { x: null })).toEqual({ x: null })
|
|
193
197
|
})
|
|
194
198
|
})
|
|
199
|
+
|
|
200
|
+
describe("pickSchemaShape", () => {
|
|
201
|
+
test("keeps only the keys the schema describes, at every depth", () => {
|
|
202
|
+
const schema = z
|
|
203
|
+
.object({
|
|
204
|
+
address: z.object({ city: z.string() }).strict(),
|
|
205
|
+
name: z.string(),
|
|
206
|
+
})
|
|
207
|
+
.strict()
|
|
208
|
+
const record = {
|
|
209
|
+
address: { city: "Geneva", geocodedAt: "2026-01-01" },
|
|
210
|
+
createdAt: new Date(),
|
|
211
|
+
id: 7,
|
|
212
|
+
name: "Ada",
|
|
213
|
+
}
|
|
214
|
+
expect(pickSchemaShape(schema, record)).toEqual({
|
|
215
|
+
address: { city: "Geneva" },
|
|
216
|
+
name: "Ada",
|
|
217
|
+
})
|
|
218
|
+
// The record is copied, never trimmed in place.
|
|
219
|
+
expect(record).toHaveProperty("id", 7)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("maps an array's element over every row", () => {
|
|
223
|
+
const schema = z.object({
|
|
224
|
+
lines: z.array(z.object({ amount: z.number(), label: z.string() })),
|
|
225
|
+
})
|
|
226
|
+
expect(
|
|
227
|
+
pickSchemaShape(schema, {
|
|
228
|
+
lines: [
|
|
229
|
+
{ amount: 1, id: 10, label: "Design" },
|
|
230
|
+
{ amount: 2, id: 11, label: "Build" },
|
|
231
|
+
],
|
|
232
|
+
})
|
|
233
|
+
).toEqual({
|
|
234
|
+
lines: [
|
|
235
|
+
{ amount: 1, label: "Design" },
|
|
236
|
+
{ amount: 2, label: "Build" },
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("unwraps optional, nullable and default, keeping null and absent keys", () => {
|
|
242
|
+
const address = z.object({ city: z.string() })
|
|
243
|
+
const schema = z.object({
|
|
244
|
+
billing: address.nullable(),
|
|
245
|
+
shipping: address.optional(),
|
|
246
|
+
tags: z.array(z.object({ label: z.string() })).default([]),
|
|
247
|
+
work: address.nullable(),
|
|
248
|
+
})
|
|
249
|
+
const picked = pickSchemaShape(schema, {
|
|
250
|
+
billing: null,
|
|
251
|
+
tags: [{ id: 1, label: "vip" }],
|
|
252
|
+
work: { city: "Bern", id: 3 },
|
|
253
|
+
})
|
|
254
|
+
expect(picked).toEqual({
|
|
255
|
+
billing: null,
|
|
256
|
+
tags: [{ label: "vip" }],
|
|
257
|
+
work: { city: "Bern" },
|
|
258
|
+
})
|
|
259
|
+
expect(picked).not.toHaveProperty("shipping")
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test("reads a pipe or a transform through its input", () => {
|
|
263
|
+
const schema = z
|
|
264
|
+
.object({
|
|
265
|
+
amount: z.string().transform(Number),
|
|
266
|
+
period: z
|
|
267
|
+
.object({ from: z.string(), to: z.string() })
|
|
268
|
+
.transform(({ from, to }) => `${from}/${to}`),
|
|
269
|
+
})
|
|
270
|
+
.transform((invoice) => ({ ...invoice, total: invoice.amount }))
|
|
271
|
+
expect(
|
|
272
|
+
pickSchemaShape(schema, {
|
|
273
|
+
amount: "12",
|
|
274
|
+
id: 4,
|
|
275
|
+
period: { from: "2026-01", id: 9, to: "2026-02" },
|
|
276
|
+
})
|
|
277
|
+
).toEqual({ amount: "12", period: { from: "2026-01", to: "2026-02" } })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test("keeps as is what the schema doesn't describe for certain", () => {
|
|
281
|
+
const schema = z.object({
|
|
282
|
+
contact: z.union([
|
|
283
|
+
z.object({ email: z.string() }),
|
|
284
|
+
z.object({ phone: z.string() }),
|
|
285
|
+
]),
|
|
286
|
+
extra: z.object({ a: z.string() }).catchall(z.string()),
|
|
287
|
+
labels: z.record(z.string(), z.object({ text: z.string() })),
|
|
288
|
+
loose: z.looseObject({ a: z.string() }),
|
|
289
|
+
meta: z.unknown(),
|
|
290
|
+
profile: z.object({ nick: z.string() }),
|
|
291
|
+
})
|
|
292
|
+
const value = {
|
|
293
|
+
contact: { email: "ada@example.com", id: 1 },
|
|
294
|
+
extra: { a: "x", b: "y" },
|
|
295
|
+
labels: { en: { id: 2, text: "Hello" } },
|
|
296
|
+
loose: { a: "x", b: "y" },
|
|
297
|
+
meta: { anything: true },
|
|
298
|
+
profile: "not an object",
|
|
299
|
+
}
|
|
300
|
+
expect(pickSchemaShape(schema, value)).toEqual(value)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("leaves a value alone without a Zod schema", () => {
|
|
304
|
+
const value = { id: 1, name: "Ada" }
|
|
305
|
+
expect(
|
|
306
|
+
pickSchemaShape({} as Parameters<typeof pickSchemaShape>[0], value)
|
|
307
|
+
).toBe(value)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test("leaves a missing key absent, for the schema default to fill", () => {
|
|
311
|
+
const schema = z.object({
|
|
312
|
+
name: z.string(),
|
|
313
|
+
role: z.string().default("member"),
|
|
314
|
+
})
|
|
315
|
+
const picked = pickSchemaShape(schema, { id: 7, name: "Ada" })
|
|
316
|
+
expect(deepMergeDefaults(extractZodDefaults(schema), picked)).toEqual({
|
|
317
|
+
name: "Ada",
|
|
318
|
+
role: "member",
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
})
|