@zeno-lib/forms 0.5.0 → 0.7.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 +27 -6
- package/dist/create-zeno-form.mjs +29 -6
- package/dist/form-element.d.mts +16 -3
- package/dist/form-element.mjs +38 -18
- package/dist/index.d.mts +3 -1
- package/dist/index.mjs +3 -1
- package/dist/lib/date-value.d.mts +24 -0
- package/dist/lib/date-value.mjs +37 -0
- package/dist/lib/default-props.d.mts +7 -0
- package/dist/lib/default-props.mjs +13 -0
- package/dist/lib/empty-value.mjs +25 -1
- package/dist/lib/form-disabled.d.mts +28 -0
- package/dist/lib/form-disabled.mjs +54 -0
- package/dist/lib/item-options.d.mts +45 -0
- package/dist/lib/item-options.mjs +70 -0
- package/dist/lib/schema-required.mjs +17 -0
- package/dist/lib/submit-action.d.mts +8 -2
- package/dist/lib/submit-action.mjs +4 -2
- package/dist/lib/use-field-control.d.mts +83 -0
- package/dist/lib/use-field-control.mjs +70 -0
- package/package.json +1 -1
- package/src/control-field.test.tsx +338 -0
- package/src/create-form.tsx +36 -28
- package/src/create-zeno-form.tsx +144 -52
- package/src/date-picker-field.test.tsx +108 -0
- package/src/default-props.test-d.ts +66 -0
- package/src/default-props.test.tsx +165 -0
- package/src/fields/checkbox-field.tsx +5 -0
- package/src/fields/checkbox-group-field.tsx +6 -2
- package/src/fields/combobox-field.tsx +107 -40
- package/src/fields/control-field.tsx +57 -0
- package/src/fields/date-picker-field.tsx +34 -11
- package/src/fields/field-shell.tsx +80 -0
- package/src/fields/index.ts +2 -0
- package/src/fields/input-field.tsx +16 -48
- package/src/fields/multi-select-field.tsx +100 -42
- package/src/fields/otp-field.tsx +8 -2
- package/src/fields/radio-group-field.tsx +9 -1
- package/src/fields/reset-button.tsx +4 -0
- package/src/fields/select-field.tsx +9 -1
- package/src/fields/slider-field.tsx +9 -1
- package/src/fields/submit-button.tsx +3 -1
- package/src/fields/switch-field.tsx +5 -0
- package/src/fields/textarea-field.tsx +16 -48
- package/src/form-dialog.test.tsx +102 -1
- package/src/form-dialog.tsx +143 -42
- package/src/form-disabled.test.tsx +329 -0
- package/src/form-element.tsx +64 -29
- package/src/grouped-options.test-d.ts +59 -0
- package/src/grouped-options.test.tsx +251 -0
- package/src/index.ts +7 -0
- package/src/lib/date-value.test.ts +14 -0
- package/src/lib/date-value.ts +70 -0
- package/src/lib/default-props.ts +19 -0
- package/src/lib/empty-value.test.ts +19 -0
- package/src/lib/empty-value.ts +45 -1
- package/src/lib/form-disabled.ts +95 -0
- package/src/lib/item-options.test.ts +11 -0
- package/src/lib/item-options.ts +165 -0
- package/src/lib/schema-required.test.ts +9 -0
- package/src/lib/schema-required.ts +33 -0
- package/src/lib/submit-action.ts +18 -4
- package/src/lib/use-field-control.ts +191 -0
- package/src/submit-action.test.tsx +103 -0
- package/src/use-app-fields.test-d.ts +51 -1
- package/src/with-form.test-d.ts +76 -0
- package/src/with-form.test.tsx +60 -0
package/src/create-zeno-form.tsx
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type {
|
|
5
5
|
AnyFormApi,
|
|
6
6
|
DeepKeys,
|
|
7
|
+
DeepValue,
|
|
7
8
|
FormAsyncValidateOrFn,
|
|
8
9
|
FormOptions,
|
|
9
10
|
FormValidateOrFn,
|
|
@@ -12,13 +13,16 @@ import { createFormHook } from "@tanstack/react-form"
|
|
|
12
13
|
import { type ComponentProps, type ReactNode, useMemo } from "react"
|
|
13
14
|
import { applyValidationError } from "./lib/apply-validation-error"
|
|
14
15
|
import { fieldContext, formContext } from "./lib/contexts"
|
|
16
|
+
import { withDefaultProps } from "./lib/default-props"
|
|
15
17
|
import { withEmptyStartValues } from "./lib/empty-value"
|
|
18
|
+
import { useDisabledOption } from "./lib/form-disabled"
|
|
16
19
|
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
|
|
17
20
|
import {
|
|
18
21
|
getRequiredPaths,
|
|
19
22
|
type StandardSchemaLike,
|
|
20
23
|
} from "./lib/schema-required"
|
|
21
24
|
import { setSubmitError } from "./lib/submit-error"
|
|
25
|
+
import type { FieldControlProps } from "./lib/use-field-control"
|
|
22
26
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
23
27
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
24
28
|
import { ValidationError } from "./lib/validation-error"
|
|
@@ -52,6 +56,11 @@ type PartialFormData<T> = T extends readonly unknown[]
|
|
|
52
56
|
: T
|
|
53
57
|
|
|
54
58
|
type ZenoFormExtras<TFormData> = {
|
|
59
|
+
/**
|
|
60
|
+
* Disable every field of the form, its submit button and `FormDialog`'s
|
|
61
|
+
* Save. A field's own `disabled` still applies.
|
|
62
|
+
*/
|
|
63
|
+
disabled?: boolean
|
|
55
64
|
/**
|
|
56
65
|
* If `true`, shipped fields skip rendering their inline `<FieldError>`
|
|
57
66
|
* message. Fields still flip `data-invalid` / `aria-invalid`.
|
|
@@ -88,18 +97,21 @@ type ManualPathExtras = {
|
|
|
88
97
|
schema?: never
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
type SyncValidator<TFormData> = undefined | FormValidateOrFn<TFormData>
|
|
101
|
+
type AsyncValidator<TFormData> = undefined | FormAsyncValidateOrFn<TFormData>
|
|
102
|
+
|
|
91
103
|
type SchemaFormOptions<
|
|
92
104
|
TFormData,
|
|
93
|
-
TOnMount extends
|
|
94
|
-
TOnChange extends
|
|
95
|
-
TOnChangeAsync extends
|
|
96
|
-
TOnBlur extends
|
|
97
|
-
TOnBlurAsync extends
|
|
98
|
-
TOnSubmit extends
|
|
99
|
-
TOnSubmitAsync extends
|
|
100
|
-
TOnDynamic extends
|
|
101
|
-
TOnDynamicAsync extends
|
|
102
|
-
TOnServer extends
|
|
105
|
+
TOnMount extends SyncValidator<TFormData>,
|
|
106
|
+
TOnChange extends SyncValidator<TFormData>,
|
|
107
|
+
TOnChangeAsync extends AsyncValidator<TFormData>,
|
|
108
|
+
TOnBlur extends SyncValidator<TFormData>,
|
|
109
|
+
TOnBlurAsync extends AsyncValidator<TFormData>,
|
|
110
|
+
TOnSubmit extends SyncValidator<TFormData>,
|
|
111
|
+
TOnSubmitAsync extends AsyncValidator<TFormData>,
|
|
112
|
+
TOnDynamic extends SyncValidator<TFormData>,
|
|
113
|
+
TOnDynamicAsync extends AsyncValidator<TFormData>,
|
|
114
|
+
TOnServer extends AsyncValidator<TFormData>,
|
|
103
115
|
TSubmitMeta,
|
|
104
116
|
> = Omit<
|
|
105
117
|
FormOptions<
|
|
@@ -123,16 +135,16 @@ type SchemaFormOptions<
|
|
|
123
135
|
|
|
124
136
|
type ManualFormOptions<
|
|
125
137
|
TFormData,
|
|
126
|
-
TOnMount extends
|
|
127
|
-
TOnChange extends
|
|
128
|
-
TOnChangeAsync extends
|
|
129
|
-
TOnBlur extends
|
|
130
|
-
TOnBlurAsync extends
|
|
131
|
-
TOnSubmit extends
|
|
132
|
-
TOnSubmitAsync extends
|
|
133
|
-
TOnDynamic extends
|
|
134
|
-
TOnDynamicAsync extends
|
|
135
|
-
TOnServer extends
|
|
138
|
+
TOnMount extends SyncValidator<TFormData>,
|
|
139
|
+
TOnChange extends SyncValidator<TFormData>,
|
|
140
|
+
TOnChangeAsync extends AsyncValidator<TFormData>,
|
|
141
|
+
TOnBlur extends SyncValidator<TFormData>,
|
|
142
|
+
TOnBlurAsync extends AsyncValidator<TFormData>,
|
|
143
|
+
TOnSubmit extends SyncValidator<TFormData>,
|
|
144
|
+
TOnSubmitAsync extends AsyncValidator<TFormData>,
|
|
145
|
+
TOnDynamic extends SyncValidator<TFormData>,
|
|
146
|
+
TOnDynamicAsync extends AsyncValidator<TFormData>,
|
|
147
|
+
TOnServer extends AsyncValidator<TFormData>,
|
|
136
148
|
TSubmitMeta,
|
|
137
149
|
> = Omit<
|
|
138
150
|
FormOptions<
|
|
@@ -156,16 +168,16 @@ type ManualFormOptions<
|
|
|
156
168
|
|
|
157
169
|
type UseFormOptions<
|
|
158
170
|
TFormData,
|
|
159
|
-
TOnMount extends
|
|
160
|
-
TOnChange extends
|
|
161
|
-
TOnChangeAsync extends
|
|
162
|
-
TOnBlur extends
|
|
163
|
-
TOnBlurAsync extends
|
|
164
|
-
TOnSubmit extends
|
|
165
|
-
TOnSubmitAsync extends
|
|
166
|
-
TOnDynamic extends
|
|
167
|
-
TOnDynamicAsync extends
|
|
168
|
-
TOnServer extends
|
|
171
|
+
TOnMount extends SyncValidator<TFormData>,
|
|
172
|
+
TOnChange extends SyncValidator<TFormData>,
|
|
173
|
+
TOnChangeAsync extends AsyncValidator<TFormData>,
|
|
174
|
+
TOnBlur extends SyncValidator<TFormData>,
|
|
175
|
+
TOnBlurAsync extends AsyncValidator<TFormData>,
|
|
176
|
+
TOnSubmit extends SyncValidator<TFormData>,
|
|
177
|
+
TOnSubmitAsync extends AsyncValidator<TFormData>,
|
|
178
|
+
TOnDynamic extends SyncValidator<TFormData>,
|
|
179
|
+
TOnDynamicAsync extends AsyncValidator<TFormData>,
|
|
180
|
+
TOnServer extends AsyncValidator<TFormData>,
|
|
169
181
|
TSubmitMeta,
|
|
170
182
|
> =
|
|
171
183
|
| SchemaFormOptions<
|
|
@@ -273,18 +285,89 @@ type WithValidators = {
|
|
|
273
285
|
// biome-ignore lint/suspicious/noExplicitAny: field components accept arbitrary props.
|
|
274
286
|
type AnyComponent = React.ComponentType<any>
|
|
275
287
|
|
|
288
|
+
// A field that hands `render` a `FieldControlProps` (the kit's `ControlField`)
|
|
289
|
+
// gets the control typed with the value at `name`, which its own generic
|
|
290
|
+
// can't see. Matching on the parameter, not on `render` as a whole, leaves a
|
|
291
|
+
// field whose `render` takes something else (or nothing) as declared.
|
|
292
|
+
type WithControlValue<P, V> = P extends {
|
|
293
|
+
render: (control: infer C) => ReactNode
|
|
294
|
+
}
|
|
295
|
+
? // biome-ignore lint/suspicious/noExplicitAny: matches any value type.
|
|
296
|
+
[C] extends [FieldControlProps<any>]
|
|
297
|
+
? Omit<P, "render"> & {
|
|
298
|
+
render: (control: FieldControlProps<V>) => ReactNode
|
|
299
|
+
}
|
|
300
|
+
: P
|
|
301
|
+
: P
|
|
302
|
+
|
|
276
303
|
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = {
|
|
277
304
|
[K in keyof FC]: <N extends DeepKeys<T>>(
|
|
278
|
-
props: ComponentProps<FC[K]
|
|
305
|
+
props: WithControlValue<ComponentProps<FC[K]>, DeepValue<T, N>> & {
|
|
306
|
+
name: N
|
|
307
|
+
} & WithValidators
|
|
279
308
|
) => ReactNode
|
|
280
309
|
}
|
|
281
310
|
|
|
311
|
+
// A component's optional props, per member of a union of props.
|
|
312
|
+
type OptionalProps<P> = P extends unknown
|
|
313
|
+
? {
|
|
314
|
+
[K in keyof P as Pick<P, K> extends Required<Pick<P, K>>
|
|
315
|
+
? never
|
|
316
|
+
: K]?: P[K]
|
|
317
|
+
}
|
|
318
|
+
: never
|
|
319
|
+
|
|
320
|
+
// Each component's defaults take only its own optional props: a required
|
|
321
|
+
// one stays required at every call site, so its default would never apply.
|
|
322
|
+
type DefaultProps<C extends Record<string, AnyComponent>> = {
|
|
323
|
+
[K in keyof C]?: OptionalProps<ComponentProps<C[K]>>
|
|
324
|
+
}
|
|
325
|
+
|
|
282
326
|
type CreateZenoFormConfig<
|
|
283
327
|
FC extends Record<string, AnyComponent>,
|
|
284
328
|
FMC extends Record<string, AnyComponent>,
|
|
285
329
|
> = {
|
|
286
330
|
fieldComponents: FC
|
|
287
331
|
formComponents: FMC
|
|
332
|
+
/**
|
|
333
|
+
* Default props for field and form components, keyed by component name,
|
|
334
|
+
* e.g. `{ MoneyField: { currency: "CHF", locale: "de-CH" } }`. A prop
|
|
335
|
+
* passed at the call site wins; one passed as `undefined` keeps the default.
|
|
336
|
+
*/
|
|
337
|
+
defaultProps?: DefaultProps<NoInfer<FC> & NoInfer<FMC>>
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// What `memo` and `forwardRef` store on their component object, which a
|
|
341
|
+
// function component must not take over.
|
|
342
|
+
const REACT_TYPE_KEYS = new Set(["$$typeof", "compare", "render", "type"])
|
|
343
|
+
|
|
344
|
+
// Wrap each component that has defaults, once, so they apply however it
|
|
345
|
+
// renders: destructured from `useForm`, as `field.X` in `AppField`, or in
|
|
346
|
+
// `withForm`.
|
|
347
|
+
function withComponentDefaults<C extends Record<string, AnyComponent>>(
|
|
348
|
+
components: C,
|
|
349
|
+
defaultProps: Partial<Record<string, object>> | undefined
|
|
350
|
+
): C {
|
|
351
|
+
const wrapped: Record<string, AnyComponent> = { ...components }
|
|
352
|
+
for (const [name, defaults] of Object.entries(defaultProps ?? {})) {
|
|
353
|
+
const Component = components[name]
|
|
354
|
+
if (Component === undefined || defaults === undefined) {
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
const Defaulted = (props: object) => (
|
|
358
|
+
<Component {...withDefaultProps(defaults, props)} />
|
|
359
|
+
)
|
|
360
|
+
// Keep the component's statics (`AddressField.Row`): the wrapper is
|
|
361
|
+
// typed as the component.
|
|
362
|
+
for (const [key, value] of Object.entries(Component)) {
|
|
363
|
+
if (!REACT_TYPE_KEYS.has(key)) {
|
|
364
|
+
Object.assign(Defaulted, { [key]: value })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
Defaulted.displayName = name
|
|
368
|
+
wrapped[name] = Defaulted
|
|
369
|
+
}
|
|
370
|
+
return wrapped as C
|
|
288
371
|
}
|
|
289
372
|
|
|
290
373
|
/**
|
|
@@ -299,10 +382,18 @@ export function createZenoForm<
|
|
|
299
382
|
FC extends Record<string, AnyComponent>,
|
|
300
383
|
FMC extends Record<string, AnyComponent>,
|
|
301
384
|
>(config: CreateZenoFormConfig<FC, FMC>) {
|
|
385
|
+
const fieldComponents = withComponentDefaults(
|
|
386
|
+
config.fieldComponents,
|
|
387
|
+
config.defaultProps
|
|
388
|
+
)
|
|
389
|
+
const formComponents = withComponentDefaults(
|
|
390
|
+
config.formComponents,
|
|
391
|
+
config.defaultProps
|
|
392
|
+
)
|
|
302
393
|
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
303
|
-
fieldComponents
|
|
394
|
+
fieldComponents,
|
|
304
395
|
fieldContext,
|
|
305
|
-
formComponents
|
|
396
|
+
formComponents,
|
|
306
397
|
formContext,
|
|
307
398
|
})
|
|
308
399
|
|
|
@@ -316,8 +407,8 @@ export function createZenoForm<
|
|
|
316
407
|
children: () => ReactNode
|
|
317
408
|
}>
|
|
318
409
|
const wrappers: Record<string, AnyComponent> = {}
|
|
319
|
-
for (const key of Object.keys(
|
|
320
|
-
const Impl =
|
|
410
|
+
for (const key of Object.keys(fieldComponents)) {
|
|
411
|
+
const Impl = fieldComponents[key] as AnyComponent
|
|
321
412
|
wrappers[key] = ({
|
|
322
413
|
name,
|
|
323
414
|
validators,
|
|
@@ -337,27 +428,25 @@ export function createZenoForm<
|
|
|
337
428
|
}, [form])
|
|
338
429
|
}
|
|
339
430
|
|
|
431
|
+
// An unset generic defaults to its constraint, which is what TanStack's own
|
|
432
|
+
// `useForm` infers, never to `undefined` or `never`: `withForm` rejects the
|
|
433
|
+
// form otherwise (`with-form.test-d.ts`).
|
|
340
434
|
function useForm<
|
|
341
435
|
TFormData,
|
|
342
|
-
TOnMount extends
|
|
343
|
-
TOnChange extends
|
|
436
|
+
TOnMount extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
437
|
+
TOnChange extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
344
438
|
TOnChangeAsync extends
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
| undefined
|
|
350
|
-
| FormAsyncValidateOrFn<TFormData> = undefined,
|
|
351
|
-
TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
439
|
+
AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
440
|
+
TOnBlur extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
441
|
+
TOnBlurAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
442
|
+
TOnSubmit extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
352
443
|
TOnSubmitAsync extends
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined,
|
|
444
|
+
AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
445
|
+
TOnDynamic extends SyncValidator<TFormData> = SyncValidator<TFormData>,
|
|
356
446
|
TOnDynamicAsync extends
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
TSubmitMeta = never,
|
|
447
|
+
AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
448
|
+
TOnServer extends AsyncValidator<TFormData> = AsyncValidator<TFormData>,
|
|
449
|
+
TSubmitMeta = unknown,
|
|
361
450
|
>(
|
|
362
451
|
options: UseFormOptions<
|
|
363
452
|
TFormData,
|
|
@@ -395,6 +484,7 @@ export function createZenoForm<
|
|
|
395
484
|
schema,
|
|
396
485
|
validators: validatorsInput,
|
|
397
486
|
validationLogic: userValidationLogic,
|
|
487
|
+
disabled = false,
|
|
398
488
|
hideFieldErrors = false,
|
|
399
489
|
requiredIndicator = true,
|
|
400
490
|
unsavedChangesWarning = false,
|
|
@@ -406,6 +496,7 @@ export function createZenoForm<
|
|
|
406
496
|
validators?: ValidationMode | NativeValidators
|
|
407
497
|
validationLogic?: NativeValidationLogic
|
|
408
498
|
defaultValues?: PartialFormData<TFormData>
|
|
499
|
+
disabled?: boolean
|
|
409
500
|
hideFieldErrors?: boolean
|
|
410
501
|
requiredIndicator?: boolean
|
|
411
502
|
unsavedChangesWarning?: boolean | "if-changed" | "if-touched"
|
|
@@ -538,11 +629,12 @@ export function createZenoForm<
|
|
|
538
629
|
...(schemaMode === undefined ? {} : { validation: schemaMode }),
|
|
539
630
|
})
|
|
540
631
|
|
|
632
|
+
useDisabledOption(form, disabled)
|
|
541
633
|
useUnsavedChangesWarning(form, unsavedChangesWarning)
|
|
542
634
|
|
|
543
635
|
const fields = useAppFields(form)
|
|
544
636
|
return useMemo(
|
|
545
|
-
() => Object.assign(form, fields,
|
|
637
|
+
() => Object.assign(form, fields, formComponents),
|
|
546
638
|
[form, fields]
|
|
547
639
|
)
|
|
548
640
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
} from "@zeno-lib/test/testing-library"
|
|
7
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
|
|
9
|
+
import { Form, FormProvider, useForm } from "./create-form"
|
|
10
|
+
import type { DateValueType } from "./lib/date-value"
|
|
11
|
+
|
|
12
|
+
const DAY_LABEL = /Day/
|
|
13
|
+
const OCTOBER_9_SELECTED = /October 9th, 2026, selected/
|
|
14
|
+
const OCTOBER_10 = /October 10th, 2026/
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
cleanup()
|
|
18
|
+
vi.unstubAllEnvs()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function Harness({
|
|
22
|
+
captured,
|
|
23
|
+
defaultValue,
|
|
24
|
+
valueType,
|
|
25
|
+
}: {
|
|
26
|
+
captured: { day?: unknown }
|
|
27
|
+
defaultValue: string
|
|
28
|
+
valueType: DateValueType
|
|
29
|
+
}) {
|
|
30
|
+
const form = useForm({
|
|
31
|
+
defaultValues: { day: defaultValue as string | undefined },
|
|
32
|
+
onSubmit: vi.fn(),
|
|
33
|
+
})
|
|
34
|
+
const { DatePickerField, Subscribe } = form
|
|
35
|
+
return (
|
|
36
|
+
<FormProvider form={form}>
|
|
37
|
+
<Form>
|
|
38
|
+
<DatePickerField
|
|
39
|
+
// `toDateString` doesn't depend on the runtime's locale.
|
|
40
|
+
formatValue={(date) => date.toDateString()}
|
|
41
|
+
label="Day"
|
|
42
|
+
name="day"
|
|
43
|
+
valueType={valueType}
|
|
44
|
+
/>
|
|
45
|
+
<Subscribe selector={(state) => state.values.day}>
|
|
46
|
+
{(day) => {
|
|
47
|
+
captured.day = day
|
|
48
|
+
return null
|
|
49
|
+
}}
|
|
50
|
+
</Subscribe>
|
|
51
|
+
</Form>
|
|
52
|
+
</FormProvider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The day before UTC's date in the evening, and the day after in the morning:
|
|
57
|
+
// a date read or written through UTC lands on the wrong day in one of them.
|
|
58
|
+
describe.each(["Pacific/Kiritimati", "America/Los_Angeles"])(
|
|
59
|
+
"DatePickerField in %s",
|
|
60
|
+
(zone) => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
vi.stubEnv("TZ", zone)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('valueType="iso-date" shows and stores the day itself', async () => {
|
|
66
|
+
const user = userEvent.setup()
|
|
67
|
+
const captured: { day?: unknown } = {}
|
|
68
|
+
render(
|
|
69
|
+
<Harness
|
|
70
|
+
captured={captured}
|
|
71
|
+
defaultValue="2026-10-09"
|
|
72
|
+
valueType="iso-date"
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
const trigger = screen.getByRole("button", { name: DAY_LABEL })
|
|
76
|
+
expect(trigger.textContent).toContain("Oct 09 2026")
|
|
77
|
+
|
|
78
|
+
fireEvent.click(trigger)
|
|
79
|
+
expect(await screen.findByRole("button", { name: OCTOBER_9_SELECTED }))
|
|
80
|
+
await user.click(screen.getByRole("button", { name: OCTOBER_10 }))
|
|
81
|
+
expect(captured.day).toBe("2026-10-10")
|
|
82
|
+
expect(trigger.textContent).toContain("Oct 10 2026")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('valueType="iso-datetime" stores a timestamp that reads back as the picked day', async () => {
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
const captured: { day?: unknown } = {}
|
|
88
|
+
render(
|
|
89
|
+
<Harness
|
|
90
|
+
captured={captured}
|
|
91
|
+
// What the field stored for October 9 in this zone.
|
|
92
|
+
defaultValue={new Date(2026, 9, 9).toISOString()}
|
|
93
|
+
valueType="iso-datetime"
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
const trigger = screen.getByRole("button", { name: DAY_LABEL })
|
|
97
|
+
expect(trigger.textContent).toContain("Oct 09 2026")
|
|
98
|
+
|
|
99
|
+
fireEvent.click(trigger)
|
|
100
|
+
await user.click(await screen.findByRole("button", { name: OCTOBER_10 }))
|
|
101
|
+
expect(typeof captured.day).toBe("string")
|
|
102
|
+
expect(new Date(captured.day as string).toDateString()).toBe(
|
|
103
|
+
"Sat Oct 10 2026"
|
|
104
|
+
)
|
|
105
|
+
expect(trigger.textContent).toContain("Oct 10 2026")
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test } from "vitest"
|
|
2
|
+
import { createZenoForm } from "./create-zeno-form"
|
|
3
|
+
import { FormError } from "./fields/form-error"
|
|
4
|
+
import { MoneyField } from "./fields/money-field"
|
|
5
|
+
import { PercentageField } from "./fields/percentage-field"
|
|
6
|
+
import { SelectField } from "./fields/select-field"
|
|
7
|
+
import { FormDialogProvider } from "./form-dialog"
|
|
8
|
+
|
|
9
|
+
const fieldComponents = { MoneyField, PercentageField, SelectField }
|
|
10
|
+
const formComponents = { FormError }
|
|
11
|
+
|
|
12
|
+
test("defaultProps takes each component's own props", () => {
|
|
13
|
+
createZenoForm({
|
|
14
|
+
defaultProps: {
|
|
15
|
+
FormError: { submitErrorMessage: "Try again later." },
|
|
16
|
+
MoneyField: { currency: "CHF", locale: "de-CH" },
|
|
17
|
+
PercentageField: { scale: "fraction" },
|
|
18
|
+
},
|
|
19
|
+
fieldComponents,
|
|
20
|
+
formComponents,
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("defaultProps rejects another component's prop", () => {
|
|
25
|
+
createZenoForm({
|
|
26
|
+
defaultProps: {
|
|
27
|
+
// @ts-expect-error — `scale` is PercentageField's
|
|
28
|
+
MoneyField: { scale: "fraction" },
|
|
29
|
+
},
|
|
30
|
+
fieldComponents,
|
|
31
|
+
formComponents,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("defaultProps rejects a required prop, which every call site still passes", () => {
|
|
36
|
+
createZenoForm({
|
|
37
|
+
defaultProps: {
|
|
38
|
+
// @ts-expect-error — `children` is required
|
|
39
|
+
SelectField: { children: null },
|
|
40
|
+
},
|
|
41
|
+
fieldComponents,
|
|
42
|
+
formComponents,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("defaultProps rejects a component the form doesn't have", () => {
|
|
47
|
+
createZenoForm({
|
|
48
|
+
defaultProps: {
|
|
49
|
+
// @ts-expect-error — no such component
|
|
50
|
+
YearField: { min: 1900 },
|
|
51
|
+
},
|
|
52
|
+
fieldComponents,
|
|
53
|
+
formComponents,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("FormDialogProvider takes the options every dialog shares", () => {
|
|
58
|
+
FormDialogProvider({
|
|
59
|
+
children: null,
|
|
60
|
+
discardPrompt: { title: "Leave?" },
|
|
61
|
+
saveFromPrompt: true,
|
|
62
|
+
submitLabel: "Speichern",
|
|
63
|
+
})
|
|
64
|
+
// @ts-expect-error — a title belongs to one dialog
|
|
65
|
+
FormDialogProvider({ children: null, title: "Person" })
|
|
66
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
2
|
+
import userEvent from "@zeno-lib/test/user-event"
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
|
+
import { createZenoForm } from "./create-zeno-form"
|
|
5
|
+
import { FormError } from "./fields/form-error"
|
|
6
|
+
import { InputField } from "./fields/input-field"
|
|
7
|
+
import { MoneyField } from "./fields/money-field"
|
|
8
|
+
import { SubmitButton } from "./fields/submit-button"
|
|
9
|
+
import { FormDialog } from "./form-dialog"
|
|
10
|
+
import { Form, FormProvider } from "./form-element"
|
|
11
|
+
import { useFormDialog } from "./lib/use-form-dialog"
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup()
|
|
15
|
+
vi.unstubAllGlobals()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const { useForm } = createZenoForm({
|
|
19
|
+
defaultProps: {
|
|
20
|
+
FormError: { submitErrorMessage: "Not saved. Try again later." },
|
|
21
|
+
MoneyField: { currency: "CHF", currencyDisplay: "code", placeholder: "0" },
|
|
22
|
+
},
|
|
23
|
+
fieldComponents: { InputField, MoneyField },
|
|
24
|
+
formComponents: { FormError, SubmitButton },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const VALUES = {
|
|
28
|
+
amount: null as number | null,
|
|
29
|
+
fee: null as number | null,
|
|
30
|
+
name: "",
|
|
31
|
+
total: null as number | null,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Harness({ onSubmit = vi.fn() }: { onSubmit?: () => unknown }) {
|
|
35
|
+
const form = useForm({ defaultValues: VALUES, onSubmit })
|
|
36
|
+
const unset: string | undefined = undefined
|
|
37
|
+
return (
|
|
38
|
+
<FormProvider form={form}>
|
|
39
|
+
<Form>
|
|
40
|
+
<form.MoneyField label="Amount" name="amount" />
|
|
41
|
+
<form.MoneyField currency="EUR" label="Fee" name="fee" />
|
|
42
|
+
<form.MoneyField currency={unset} label="Total" name="total" />
|
|
43
|
+
<form.InputField label="Name" name="name" />
|
|
44
|
+
<form.FormError />
|
|
45
|
+
<form.SubmitButton>Submit</form.SubmitButton>
|
|
46
|
+
</Form>
|
|
47
|
+
</FormProvider>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function addonOf(label: string) {
|
|
52
|
+
return screen
|
|
53
|
+
.getByLabelText(label)
|
|
54
|
+
.closest("[data-slot=input-group]")
|
|
55
|
+
?.querySelector("[data-slot=input-group-addon]")?.textContent
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("createZenoForm({ defaultProps })", () => {
|
|
59
|
+
test("a field renders with its defaults, and a prop passed at the call site wins", () => {
|
|
60
|
+
render(<Harness />)
|
|
61
|
+
expect(addonOf("Amount")).toBe("CHF")
|
|
62
|
+
expect(addonOf("Fee")).toBe("EUR")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("a prop passed as undefined keeps the default", () => {
|
|
66
|
+
render(<Harness />)
|
|
67
|
+
expect(addonOf("Total")).toBe("CHF")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("a component with no defaults renders as before", () => {
|
|
71
|
+
render(<Harness />)
|
|
72
|
+
expect(screen.getByLabelText("Amount").getAttribute("placeholder")).toBe(
|
|
73
|
+
"0"
|
|
74
|
+
)
|
|
75
|
+
// InputField shares MoneyField's props, but not its defaults.
|
|
76
|
+
expect(screen.getByLabelText("Name").hasAttribute("placeholder")).toBe(
|
|
77
|
+
false
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("a form component renders with its defaults", async () => {
|
|
82
|
+
vi.stubGlobal("reportError", vi.fn())
|
|
83
|
+
const user = userEvent.setup()
|
|
84
|
+
render(
|
|
85
|
+
<Harness
|
|
86
|
+
onSubmit={() => {
|
|
87
|
+
throw new Error("offline")
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
await user.click(screen.getByRole("button", { name: "Submit" }))
|
|
92
|
+
expect((await screen.findByRole("alert")).textContent).toBe(
|
|
93
|
+
"Not saved. Try again later."
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("a FormDialog renders the form's FormError, with its defaults", async () => {
|
|
98
|
+
vi.stubGlobal("reportError", vi.fn())
|
|
99
|
+
const user = userEvent.setup()
|
|
100
|
+
function DialogHarness() {
|
|
101
|
+
const dialog = useFormDialog({ defaultValues: VALUES })
|
|
102
|
+
const form = useForm({
|
|
103
|
+
defaultValues: dialog.defaultValues,
|
|
104
|
+
onSubmit: () => {
|
|
105
|
+
throw new Error("offline")
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<button onClick={() => dialog.open()} type="button">
|
|
111
|
+
New
|
|
112
|
+
</button>
|
|
113
|
+
<FormDialog dialog={dialog} form={form} title="Payment">
|
|
114
|
+
<form.InputField label="Name" name="name" />
|
|
115
|
+
</FormDialog>
|
|
116
|
+
</>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
render(<DialogHarness />)
|
|
120
|
+
await user.click(screen.getByRole("button", { name: "New" }))
|
|
121
|
+
await user.click(await screen.findByRole("button", { name: "Save" }))
|
|
122
|
+
expect((await screen.findByRole("alert")).textContent).toBe(
|
|
123
|
+
"Not saved. Try again later."
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("a component with defaults keeps its statics", () => {
|
|
128
|
+
function Hint({ text = "None" }: { text?: string }) {
|
|
129
|
+
return <p>{text}</p>
|
|
130
|
+
}
|
|
131
|
+
Hint.Icon = () => <span>i</span>
|
|
132
|
+
const { useForm: useHintForm } = createZenoForm({
|
|
133
|
+
defaultProps: { Hint: { text: "Marked fields are required" } },
|
|
134
|
+
fieldComponents: { InputField },
|
|
135
|
+
formComponents: { Hint },
|
|
136
|
+
})
|
|
137
|
+
function HintHarness() {
|
|
138
|
+
const form = useHintForm({ defaultValues: {}, onSubmit: vi.fn() })
|
|
139
|
+
return (
|
|
140
|
+
<FormProvider form={form}>
|
|
141
|
+
<form.Hint />
|
|
142
|
+
<form.Hint.Icon />
|
|
143
|
+
</FormProvider>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
render(<HintHarness />)
|
|
147
|
+
screen.getByText("Marked fields are required")
|
|
148
|
+
screen.getByText("i")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("a field rendered through AppField gets its defaults too", () => {
|
|
152
|
+
function AppFieldHarness() {
|
|
153
|
+
const form = useForm({ defaultValues: VALUES, onSubmit: vi.fn() })
|
|
154
|
+
return (
|
|
155
|
+
<FormProvider form={form}>
|
|
156
|
+
<form.AppField name="amount">
|
|
157
|
+
{(field) => <field.MoneyField label="Amount" />}
|
|
158
|
+
</form.AppField>
|
|
159
|
+
</FormProvider>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
render(<AppFieldHarness />)
|
|
163
|
+
expect(addonOf("Amount")).toBe("CHF")
|
|
164
|
+
})
|
|
165
|
+
})
|