@zeno-lib/forms 0.8.0 → 0.9.1
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 +52 -17
- package/dist/lib/use-rebased-default-values.mjs +5 -4
- package/package.json +1 -1
- package/src/composition.test.tsx +140 -0
- package/src/create-zeno-form.tsx +208 -30
- package/src/default-props.test.tsx +25 -1
- package/src/form-dialog.test.tsx +8 -1
- package/src/lib/use-rebased-default-values.ts +12 -7
- package/src/use-form.reset-defaults.test.tsx +26 -21
- package/src/with-form.test-d.ts +25 -0
- package/src/with-form.test.tsx +2 -11
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FieldControlProps } from "./lib/use-field-control.mjs";
|
|
2
2
|
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
3
|
-
import { DeepKeys, DeepValue, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
4
|
-
import { ComponentProps, ReactNode } from "react";
|
|
3
|
+
import { AppFieldExtendedReactFieldGroupApi, AppFieldExtendedReactFormApi, BaseFormOptions, DeepKeys, DeepValue, FieldsMap, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn, WithFormProps } from "@tanstack/react-form";
|
|
4
|
+
import { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
|
5
5
|
//#region src/create-zeno-form.d.ts
|
|
6
6
|
type StandardSchema<T> = {
|
|
7
7
|
readonly "~standard": {
|
|
@@ -122,17 +122,26 @@ type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends R
|
|
|
122
122
|
*/
|
|
123
123
|
export declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
|
|
124
124
|
useAppFields: <TForm extends AnyAppForm>(form: TForm) => FieldWrappers<FC, FormDataOf<TForm>>;
|
|
125
|
-
useAppForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) =>
|
|
125
|
+
useAppForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>;
|
|
126
126
|
useForm: <TFormData, TOnMount extends SyncValidator<TFormData> = SyncValidator<TFormData>, TOnChange extends SyncValidator<TFormData> = SyncValidator<TFormData>, TOnChangeAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>, TOnBlur extends SyncValidator<TFormData> = SyncValidator<TFormData>, TOnBlurAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>, TOnSubmit extends SyncValidator<TFormData> = SyncValidator<TFormData>, TOnSubmitAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>, TOnDynamic extends SyncValidator<TFormData> = SyncValidator<TFormData>, TOnDynamicAsync extends AsyncValidator<TFormData> = AsyncValidator<TFormData>, TOnServer extends AsyncValidator<TFormData> = AsyncValidator<TFormData>, TSubmitMeta = unknown>(options: UseFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").FormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & import("@tanstack/react-form").ReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & NoInfer<FMC> & {
|
|
127
127
|
AppField: import("@tanstack/react-form").FieldComponent<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, NoInfer<FC>>;
|
|
128
|
-
AppForm: import("react").ComponentType<
|
|
129
|
-
} & FieldWrappers<FC, FormDataOf<
|
|
130
|
-
withFieldGroup: <TFieldGroupData, TSubmitMeta, TRenderProps extends object = {}>({ render,
|
|
131
|
-
|
|
128
|
+
AppForm: import("react").ComponentType<PropsWithChildren<{}>>;
|
|
129
|
+
} & FieldWrappers<FC, FormDataOf<AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>>> & FMC;
|
|
130
|
+
withFieldGroup: <TFieldGroupData, TSubmitMeta, TRenderProps extends object = {}>({ render, ...options }: BaseFormOptions<TFieldGroupData, TSubmitMeta> & {
|
|
131
|
+
props?: TRenderProps;
|
|
132
|
+
render: (props: PropsWithChildren<NoInfer<TRenderProps> & {
|
|
133
|
+
group: AppFieldExtendedReactFieldGroupApi<unknown, TFieldGroupData, string | FieldsMap<unknown, TFieldGroupData>, undefined | FormValidateOrFn<unknown>, undefined | FormValidateOrFn<unknown>, undefined | FormAsyncValidateOrFn<unknown>, undefined | FormValidateOrFn<unknown>, undefined | FormAsyncValidateOrFn<unknown>, undefined | FormValidateOrFn<unknown>, undefined | FormAsyncValidateOrFn<unknown>, undefined | FormValidateOrFn<unknown>, undefined | FormAsyncValidateOrFn<unknown>, undefined | FormAsyncValidateOrFn<unknown>, unknown extends TSubmitMeta ? never : TSubmitMeta, FC, FMC & FieldWrappers<FC, TFieldGroupData>>;
|
|
134
|
+
}>) => ReactNode;
|
|
135
|
+
}) => <TFormData, TFields extends import("@tanstack/react-form").DeepKeysOfType<TFormData, TFieldGroupData | null | undefined> | FieldsMap<TFormData, TFieldGroupData>, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TFormSubmitMeta>(params: PropsWithChildren<NoInfer<TRenderProps> & {
|
|
136
|
+
form: AppFieldExtendedReactFieldGroupApi<unknown, TFormData, string | FieldsMap<unknown, TFormData>, any, any, any, any, any, any, any, any, any, any, unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, FC, FMC> | AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, FC, FMC>;
|
|
132
137
|
fields: TFields;
|
|
133
138
|
}>) => ReturnType<import("react").FunctionComponent>;
|
|
134
|
-
withForm: <TFormData, TOnMount extends
|
|
135
|
-
|
|
139
|
+
withForm: <TFormData, TOnMount extends SyncValidator<TFormData>, TOnChange extends SyncValidator<TFormData>, TOnChangeAsync extends AsyncValidator<TFormData>, TOnBlur extends SyncValidator<TFormData>, TOnBlurAsync extends AsyncValidator<TFormData>, TOnSubmit extends SyncValidator<TFormData>, TOnSubmitAsync extends AsyncValidator<TFormData>, TOnDynamic extends SyncValidator<TFormData>, TOnDynamicAsync extends AsyncValidator<TFormData>, TOnServer extends AsyncValidator<TFormData>, TSubmitMeta, TRenderProps extends object = {}>({ render, ...options }: Omit<WithFormProps<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC, TRenderProps>, "render"> & {
|
|
140
|
+
render: (props: PropsWithChildren<NoInfer<TRenderProps> & {
|
|
141
|
+
form: AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC & FieldWrappers<FC, TFormData>>;
|
|
142
|
+
}>) => ReactNode;
|
|
143
|
+
}) => import("react").FunctionComponent<PropsWithChildren<NoInfer<[unknown] extends [TRenderProps] ? any : TRenderProps> & {
|
|
144
|
+
form: AppFieldExtendedReactFormApi<[unknown] extends [TFormData] ? any : TFormData, [FormValidateOrFn<TFormData> | undefined] extends [TOnMount] ? [TOnMount] extends [TOnMount & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnMount : TOnMount, [FormValidateOrFn<TFormData> | undefined] extends [TOnChange] ? [TOnChange] extends [TOnChange & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChange : TOnChange, [FormValidateOrFn<TFormData> | undefined] extends [TOnChangeAsync] ? [TOnChangeAsync] extends [TOnChangeAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChangeAsync : TOnChangeAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlur] ? [TOnBlur] extends [TOnBlur & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlur : TOnBlur, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlurAsync] ? [TOnBlurAsync] extends [TOnBlurAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlurAsync : TOnBlurAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmit] ? [TOnSubmit] extends [TOnSubmit & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmit : TOnSubmit, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmitAsync] ? [TOnSubmitAsync] extends [TOnSubmitAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmitAsync : TOnSubmitAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamic] ? [TOnDynamic] extends [TOnDynamic & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamic : TOnDynamic, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamicAsync] ? [TOnDynamicAsync] extends [TOnDynamicAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamicAsync : TOnDynamicAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnServer] ? [TOnServer] extends [TOnServer & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnServer : TOnServer, [unknown] extends [TSubmitMeta] ? any : TSubmitMeta, [unknown] extends [FC] ? any : FC, [unknown] extends [FMC] ? any : FMC>;
|
|
136
145
|
}>>;
|
|
137
146
|
};
|
|
138
147
|
//#endregion
|
|
@@ -62,27 +62,63 @@ function withComponentDefaults(components, defaultProps) {
|
|
|
62
62
|
function createZenoForm(config) {
|
|
63
63
|
const fieldComponents = withComponentDefaults(config.fieldComponents, config.defaultProps);
|
|
64
64
|
const formComponents = withComponentDefaults(config.formComponents, config.defaultProps);
|
|
65
|
-
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
65
|
+
const { useAppForm, withFieldGroup: withTanStackFieldGroup, withForm: withTanStackForm } = createFormHook({
|
|
66
66
|
fieldComponents,
|
|
67
67
|
fieldContext,
|
|
68
68
|
formComponents,
|
|
69
69
|
formContext
|
|
70
70
|
});
|
|
71
|
+
const fieldsByForm = /* @__PURE__ */ new WeakMap();
|
|
72
|
+
function getAppFields(form) {
|
|
73
|
+
const cached = fieldsByForm.get(form);
|
|
74
|
+
if (cached !== void 0) return cached;
|
|
75
|
+
const Field = form.AppField;
|
|
76
|
+
const wrappers = {};
|
|
77
|
+
for (const key of Object.keys(fieldComponents)) {
|
|
78
|
+
const Impl = fieldComponents[key];
|
|
79
|
+
wrappers[key] = ({ name, validators, listeners, ...props }) => /* @__PURE__ */ jsx(Field, {
|
|
80
|
+
listeners,
|
|
81
|
+
name,
|
|
82
|
+
validators,
|
|
83
|
+
children: () => /* @__PURE__ */ jsx(Impl, { ...props })
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
fieldsByForm.set(form, wrappers);
|
|
87
|
+
return wrappers;
|
|
88
|
+
}
|
|
71
89
|
function useAppFields(form) {
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
return getAppFields(form);
|
|
91
|
+
}
|
|
92
|
+
function withNamedFields(target) {
|
|
93
|
+
for (const [name, field] of Object.entries(getAppFields(target))) if (!(name in target)) Object.assign(target, { [name]: field });
|
|
94
|
+
return target;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* TanStack's `withForm`, whose `render` gets a `form` carrying the typed
|
|
98
|
+
* fields, as `useForm`'s does: `<form.InputField name="email" />`.
|
|
99
|
+
*/
|
|
100
|
+
function withForm({ render, ...options }) {
|
|
101
|
+
return withTanStackForm({
|
|
102
|
+
...options,
|
|
103
|
+
render: (props) => render({
|
|
104
|
+
...props,
|
|
105
|
+
form: withNamedFields(props.form)
|
|
106
|
+
})
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* TanStack's `withFieldGroup`, whose `render` gets a `group` carrying the
|
|
111
|
+
* typed fields of the group's own values, as `useForm`'s form does:
|
|
112
|
+
* `<group.InputField name="street" />`.
|
|
113
|
+
*/
|
|
114
|
+
function withFieldGroup({ render, ...options }) {
|
|
115
|
+
return withTanStackFieldGroup({
|
|
116
|
+
...options,
|
|
117
|
+
render: (props) => render({
|
|
118
|
+
...props,
|
|
119
|
+
group: withNamedFields(props.group)
|
|
120
|
+
})
|
|
121
|
+
});
|
|
86
122
|
}
|
|
87
123
|
function useForm(options) {
|
|
88
124
|
const { schema, validators: validatorsInput, validationLogic: userValidationLogic, disabled = false, hideFieldErrors = false, requiredIndicator = true, unsavedChangesWarning = false, defaultValues: userDefaultValues, onSubmit: userOnSubmit, ...rest } = options;
|
|
@@ -145,8 +181,7 @@ function createZenoForm(config) {
|
|
|
145
181
|
});
|
|
146
182
|
useDisabledOption(form, disabled);
|
|
147
183
|
useUnsavedChangesWarning(form, unsavedChangesWarning);
|
|
148
|
-
|
|
149
|
-
return useMemo(() => Object.assign(form, fields, formComponents), [form, fields]);
|
|
184
|
+
return useMemo(() => Object.assign(form, getAppFields(form), formComponents), [form]);
|
|
150
185
|
}
|
|
151
186
|
return {
|
|
152
187
|
useAppFields,
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { evaluate } from "@tanstack/react-form";
|
|
3
|
-
import { useCallback, useRef } from "react";
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
4
4
|
//#region src/lib/use-rebased-default-values.ts
|
|
5
5
|
function useRebasedDefaultValues(callerDefaults) {
|
|
6
6
|
const liveForm = useRef(null);
|
|
7
7
|
const lastCallerDefaults = useRef(callerDefaults);
|
|
8
8
|
let defaultValues = callerDefaults;
|
|
9
|
-
if (evaluate(lastCallerDefaults.current, callerDefaults))
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
if (liveForm.current && evaluate(lastCallerDefaults.current, callerDefaults)) defaultValues = liveForm.current.options.defaultValues;
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
lastCallerDefaults.current = callerDefaults;
|
|
12
|
+
}, [callerDefaults]);
|
|
12
13
|
const onMount = useCallback((props) => {
|
|
13
14
|
liveForm.current = props.formApi;
|
|
14
15
|
}, []);
|
package/package.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { cleanup, render, screen, within } 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 { z } from "zod"
|
|
5
|
+
import {
|
|
6
|
+
Form,
|
|
7
|
+
FormProvider,
|
|
8
|
+
useAppForm,
|
|
9
|
+
useForm,
|
|
10
|
+
withFieldGroup,
|
|
11
|
+
withForm,
|
|
12
|
+
} from "./create-form"
|
|
13
|
+
|
|
14
|
+
const STREET_LABEL = /Street/
|
|
15
|
+
const CITY_LABEL = /City/
|
|
16
|
+
const BILLING_STREET_LABEL = /Billing street/
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
cleanup()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const address = z.object({ city: z.string(), street: z.string() })
|
|
23
|
+
const schema = z.object({ billing: address, shipping: address })
|
|
24
|
+
type Values = z.infer<typeof schema>
|
|
25
|
+
const VALUES: Values = {
|
|
26
|
+
billing: { city: "", street: "" },
|
|
27
|
+
shipping: { city: "", street: "" },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const AddressGroup = withFieldGroup({
|
|
31
|
+
defaultValues: VALUES.billing,
|
|
32
|
+
props: { title: "" },
|
|
33
|
+
render: ({ group, title }) => (
|
|
34
|
+
<fieldset aria-label={title}>
|
|
35
|
+
<group.InputField label="Street" name="street" />
|
|
36
|
+
<group.InputField label="City" name="city" />
|
|
37
|
+
</fieldset>
|
|
38
|
+
),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("withFieldGroup", () => {
|
|
42
|
+
test("a group's named field binds under the group's path", async () => {
|
|
43
|
+
const user = userEvent.setup()
|
|
44
|
+
let current: { state: { values: Values } } | undefined
|
|
45
|
+
function H() {
|
|
46
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
47
|
+
current = form
|
|
48
|
+
return (
|
|
49
|
+
<FormProvider form={form}>
|
|
50
|
+
<Form>
|
|
51
|
+
<AddressGroup fields="billing" form={form} title="Billing" />
|
|
52
|
+
<AddressGroup fields="shipping" form={form} title="Shipping" />
|
|
53
|
+
</Form>
|
|
54
|
+
</FormProvider>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
render(<H />)
|
|
58
|
+
const shipping = within(screen.getByRole("group", { name: "Shipping" }))
|
|
59
|
+
const street = shipping.getByLabelText(STREET_LABEL) as HTMLInputElement
|
|
60
|
+
expect(street.name).toBe("shipping.street")
|
|
61
|
+
await user.type(street, "Main St")
|
|
62
|
+
expect(current?.state.values.shipping.street).toBe("Main St")
|
|
63
|
+
expect(current?.state.values.billing.street).toBe("")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("a group nested in a group binds under both paths", async () => {
|
|
67
|
+
const user = userEvent.setup()
|
|
68
|
+
const Nested = withFieldGroup({
|
|
69
|
+
defaultValues: { billing: VALUES.billing },
|
|
70
|
+
render: ({ group }) => (
|
|
71
|
+
<AddressGroup fields="billing" form={group} title="Nested" />
|
|
72
|
+
),
|
|
73
|
+
})
|
|
74
|
+
const nestedSchema = z.object({ order: z.object({ billing: address }) })
|
|
75
|
+
let current: { state: { values: z.infer<typeof nestedSchema> } } | undefined
|
|
76
|
+
function H() {
|
|
77
|
+
const form = useForm({ onSubmit: vi.fn(), schema: nestedSchema })
|
|
78
|
+
current = form
|
|
79
|
+
return (
|
|
80
|
+
<FormProvider form={form}>
|
|
81
|
+
<Form>
|
|
82
|
+
<Nested fields="order" form={form} />
|
|
83
|
+
</Form>
|
|
84
|
+
</FormProvider>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
render(<H />)
|
|
88
|
+
const city = screen.getByLabelText(CITY_LABEL) as HTMLInputElement
|
|
89
|
+
expect(city.name).toBe("order.billing.city")
|
|
90
|
+
await user.type(city, "Bern")
|
|
91
|
+
expect(current?.state.values.order.billing.city).toBe("Bern")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("withForm", () => {
|
|
96
|
+
const BillingSection = withForm({
|
|
97
|
+
defaultValues: VALUES,
|
|
98
|
+
render: ({ form }) => (
|
|
99
|
+
<form.InputField label="Billing street" name="billing.street" />
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("a section's form has the fields when useAppForm made it", async () => {
|
|
104
|
+
const user = userEvent.setup()
|
|
105
|
+
let current: { state: { values: Values } } | undefined
|
|
106
|
+
function H() {
|
|
107
|
+
const form = useAppForm({ defaultValues: VALUES, onSubmit: vi.fn() })
|
|
108
|
+
current = form
|
|
109
|
+
return (
|
|
110
|
+
<FormProvider form={form}>
|
|
111
|
+
<Form>
|
|
112
|
+
<BillingSection form={form} />
|
|
113
|
+
</Form>
|
|
114
|
+
</FormProvider>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
render(<H />)
|
|
118
|
+
await user.type(screen.getByLabelText(BILLING_STREET_LABEL), "Main St")
|
|
119
|
+
expect(current?.state.values.billing.street).toBe("Main St")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test("a section keeps the fields of useForm's form as they are", () => {
|
|
123
|
+
const seen: unknown[] = []
|
|
124
|
+
function H() {
|
|
125
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
126
|
+
seen.push(form.InputField)
|
|
127
|
+
return (
|
|
128
|
+
<FormProvider form={form}>
|
|
129
|
+
<Form>
|
|
130
|
+
<BillingSection form={form} />
|
|
131
|
+
</Form>
|
|
132
|
+
</FormProvider>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
const { rerender } = render(<H />)
|
|
136
|
+
rerender(<H />)
|
|
137
|
+
expect(seen).toHaveLength(2)
|
|
138
|
+
expect(seen[1]).toBe(seen[0])
|
|
139
|
+
})
|
|
140
|
+
})
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -3,14 +3,24 @@
|
|
|
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"
|
|
@@ -394,42 +404,211 @@ export function createZenoForm<
|
|
|
394
404
|
config.formComponents,
|
|
395
405
|
config.defaultProps
|
|
396
406
|
)
|
|
397
|
-
const {
|
|
407
|
+
const {
|
|
408
|
+
useAppForm,
|
|
409
|
+
withFieldGroup: withTanStackFieldGroup,
|
|
410
|
+
withForm: withTanStackForm,
|
|
411
|
+
} = createFormHook({
|
|
398
412
|
fieldComponents,
|
|
399
413
|
fieldContext,
|
|
400
414
|
formComponents,
|
|
401
415
|
formContext,
|
|
402
416
|
})
|
|
403
417
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
+
}: {
|
|
408
443
|
name: string
|
|
409
444
|
validators?: AnyFieldValidators
|
|
410
445
|
listeners?: AnyFieldListeners
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 })
|
|
430
467
|
}
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
})
|
|
433
612
|
}
|
|
434
613
|
|
|
435
614
|
// An unset generic defaults to its constraint, which is what TanStack's own
|
|
@@ -647,10 +826,9 @@ export function createZenoForm<
|
|
|
647
826
|
useDisabledOption(form, disabled)
|
|
648
827
|
useUnsavedChangesWarning(form, unsavedChangesWarning)
|
|
649
828
|
|
|
650
|
-
const fields = useAppFields(form)
|
|
651
829
|
return useMemo(
|
|
652
|
-
() => Object.assign(form,
|
|
653
|
-
[form
|
|
830
|
+
() => Object.assign(form, getAppFields(form), formComponents),
|
|
831
|
+
[form]
|
|
654
832
|
)
|
|
655
833
|
}
|
|
656
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
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
within,
|
|
8
8
|
} from "@zeno-lib/test/testing-library"
|
|
9
9
|
import userEvent from "@zeno-lib/test/user-event"
|
|
10
|
-
import { type ReactNode, useState } from "react"
|
|
10
|
+
import { type ReactNode, StrictMode, useState } from "react"
|
|
11
11
|
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest"
|
|
12
12
|
import { z } from "zod"
|
|
13
13
|
import { useForm } from "./create-form"
|
|
@@ -178,6 +178,13 @@ describe("FormDialog", () => {
|
|
|
178
178
|
expect(name.value).toBe("Ada")
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
test("opens with per-session defaults under Strict Mode", async () => {
|
|
182
|
+
const user = userEvent.setup()
|
|
183
|
+
render(<Harness />, { wrapper: StrictMode })
|
|
184
|
+
const name = await openEdit(user)
|
|
185
|
+
expect(name.value).toBe("Ada")
|
|
186
|
+
})
|
|
187
|
+
|
|
181
188
|
test("an edit opened on a record with keys the schema lacks saves to a .strict() schema", async () => {
|
|
182
189
|
const user = userEvent.setup()
|
|
183
190
|
const strictSchema = schema.strict()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { type AnyFormApi, evaluate } from "@tanstack/react-form"
|
|
4
|
-
import { useCallback, useRef } from "react"
|
|
4
|
+
import { useCallback, useLayoutEffect, useRef } from "react"
|
|
5
5
|
|
|
6
6
|
// TanStack's `useForm` calls `formApi.update(options)` after every render. While
|
|
7
7
|
// the form is untouched, `update` re-applies `options.defaultValues` whenever it
|
|
@@ -24,14 +24,19 @@ function useRebasedDefaultValues<T>(callerDefaults: T): {
|
|
|
24
24
|
const lastCallerDefaults = useRef(callerDefaults)
|
|
25
25
|
|
|
26
26
|
let defaultValues = callerDefaults
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
lastCallerDefaults.current = callerDefaults
|
|
27
|
+
if (
|
|
28
|
+
liveForm.current &&
|
|
29
|
+
evaluate(lastCallerDefaults.current, callerDefaults)
|
|
30
|
+
) {
|
|
31
|
+
defaultValues = liveForm.current.options.defaultValues as T
|
|
33
32
|
}
|
|
34
33
|
|
|
34
|
+
// Write on commit, not during render: under Strict Mode the second render
|
|
35
|
+
// would see the new defaults as unchanged and forward the old ones.
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
lastCallerDefaults.current = callerDefaults
|
|
38
|
+
}, [callerDefaults])
|
|
39
|
+
|
|
35
40
|
const onMount = useCallback((props: { formApi: AnyFormApi }) => {
|
|
36
41
|
liveForm.current = props.formApi
|
|
37
42
|
}, [])
|
|
@@ -65,30 +65,35 @@ function titleInput() {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
describe("useForm — reset(values) rebases defaults", () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
for (const reactStrictMode of [false, true]) {
|
|
69
|
+
test(`reset after submit sticks across rerenders with fixed defaults (reactStrictMode: ${reactStrictMode})`, async () => {
|
|
70
|
+
const user = userEvent.setup()
|
|
71
|
+
let state: Captured | undefined
|
|
72
|
+
const capture = vi.fn((next: Captured) => {
|
|
73
|
+
state = next
|
|
74
|
+
})
|
|
75
|
+
const record = { title: "Draft" }
|
|
76
|
+
const { rerender } = render(
|
|
77
|
+
<EditTitle capture={capture} record={record} />,
|
|
78
|
+
{ reactStrictMode }
|
|
79
|
+
)
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
await user.clear(titleInput())
|
|
82
|
+
await user.type(titleInput(), "Saved title")
|
|
83
|
+
await user.click(screen.getByRole("button", { name: SAVE }))
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
await waitFor(() => expect(state?.isTouched).toBe(false))
|
|
86
|
+
for (const tick of [1, 2, 3]) {
|
|
87
|
+
rerender(<EditTitle capture={capture} record={record} tick={tick} />)
|
|
88
|
+
}
|
|
89
|
+
await act(() => Promise.resolve())
|
|
86
90
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
expect(titleInput().value).toBe("Saved title")
|
|
92
|
+
expect(state?.values).toEqual({ title: "Saved title" })
|
|
93
|
+
expect(state?.isDefaultValue).toBe(true)
|
|
94
|
+
expect(state?.isDirty).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
92
97
|
|
|
93
98
|
test("new caller defaults apply while the form is untouched", async () => {
|
|
94
99
|
let state: Captured | undefined
|
package/src/with-form.test-d.ts
CHANGED
|
@@ -37,6 +37,31 @@ const AddressGroup = withFieldGroup({
|
|
|
37
37
|
render: () => null,
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
// A section's `form` and a group's `group` carry the fields themselves.
|
|
41
|
+
withForm({
|
|
42
|
+
defaultValues: VALUES,
|
|
43
|
+
render: ({ form }) => {
|
|
44
|
+
form.InputField({ name: "address.city" })
|
|
45
|
+
// @ts-expect-error — not a path of the form
|
|
46
|
+
form.InputField({ name: "phone" })
|
|
47
|
+
return null
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
withFieldGroup({
|
|
52
|
+
defaultValues: VALUES.address,
|
|
53
|
+
props: { label: "" },
|
|
54
|
+
render: ({ group, label }) => {
|
|
55
|
+
expectTypeOf(label).toEqualTypeOf<string>()
|
|
56
|
+
expectTypeOf(group.state.values).toEqualTypeOf<Values["address"]>()
|
|
57
|
+
const { InputField } = group
|
|
58
|
+
InputField({ name: "street" })
|
|
59
|
+
// @ts-expect-error — a path of the form, not of the group
|
|
60
|
+
InputField({ name: "address.street" })
|
|
61
|
+
return null
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
40
65
|
test("withForm takes the form useForm returns, with a schema", () => {
|
|
41
66
|
const form = useForm({ onSubmit: () => undefined, schema })
|
|
42
67
|
EmailSection({ form, title: "Email" })
|
package/src/with-form.test.tsx
CHANGED
|
@@ -2,13 +2,7 @@ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
|
|
|
2
2
|
import userEvent from "@zeno-lib/test/user-event"
|
|
3
3
|
import { afterEach, expect, test, vi } from "vitest"
|
|
4
4
|
import { z } from "zod"
|
|
5
|
-
import {
|
|
6
|
-
Form,
|
|
7
|
-
FormProvider,
|
|
8
|
-
useAppFields,
|
|
9
|
-
useForm,
|
|
10
|
-
withForm,
|
|
11
|
-
} from "./create-form"
|
|
5
|
+
import { Form, FormProvider, useForm, withForm } from "./create-form"
|
|
12
6
|
|
|
13
7
|
const CITY = /City/
|
|
14
8
|
|
|
@@ -24,10 +18,7 @@ type Profile = z.infer<typeof schema>
|
|
|
24
18
|
|
|
25
19
|
const AddressSection = withForm({
|
|
26
20
|
defaultValues: {} as Profile,
|
|
27
|
-
render: ({ form }) =>
|
|
28
|
-
const { InputField } = useAppFields(form)
|
|
29
|
-
return <InputField label="City" name="address.city" />
|
|
30
|
-
},
|
|
21
|
+
render: ({ form }) => <form.InputField label="City" name="address.city" />,
|
|
31
22
|
})
|
|
32
23
|
|
|
33
24
|
test("a withForm section edits and validates the parent's form", async () => {
|