From 25173196f3b689d919015cf8e7df8254b9e3090e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 9 Oct 2022 18:01:44 +0200 Subject: [PATCH] feat: expose controlled values on useForm (#3924) --- docs/src/pages/api/use-form.mdx | 34 +++- .../guide/composition-api/handling-forms.mdx | 57 ++++++- packages/vee-validate/src/Form.ts | 3 + packages/vee-validate/src/types.ts | 12 +- packages/vee-validate/src/useForm.ts | 154 ++++++++++-------- packages/vee-validate/tests/useForm.spec.ts | 114 ++++++++++++- 6 files changed, 298 insertions(+), 76 deletions(-) diff --git a/docs/src/pages/api/use-form.mdx b/docs/src/pages/api/use-form.mdx index bd6d64bea..37c8dced1 100644 --- a/docs/src/pages/api/use-form.mdx +++ b/docs/src/pages/api/use-form.mdx @@ -171,6 +171,15 @@ interface FormMeta> { initialValues?: TValues; } +type InvalidSubmissionHandler = ( + ctx: InvalidSubmissionContext +) => void; + +type HandleSubmitFactory = ( + cb: SubmissionHandler, + onSubmitValidationErrorCb?: InvalidSubmissionHandler +) => (e?: Event) => Promise; + type useForm = (opts?: FormOptions) => { values: TValues; // current form values submitCount: Ref; // the number of submission attempts @@ -190,10 +199,7 @@ type useForm = (opts?: FormOptions) => { validateField(field: keyof TValues): Promise; useFieldModel(path: MaybeRef): Ref; useFieldModel(paths: [...MaybeRef[]]): MapValues; - handleSubmit( - cb: SubmissionHandler, - onSubmitValidationErrorCb?: InvalidSubmissionHandler - ): (e?: Event) => Promise; + handleSubmit: HandleSubmitFactory & { withControlled: HandleSubmitFactory }; }; ``` @@ -537,6 +543,26 @@ const onSubmit = handleSubmit((values, actions) => { }); ``` +`handleSubmit` contains a `withControlled` function that you can use to only submit fields controlled by `useField` or `useFieldModel`. Read the [guide](/guide/composition-api/handling-forms) for more information. + +```vue + + + +``` + You can use `handleSubmit` to submit **virtual forms** that may use `form` elements or not. As you may have noticed the snippet above doesn't really care if you are using forms or not. diff --git a/docs/src/pages/guide/composition-api/handling-forms.mdx b/docs/src/pages/guide/composition-api/handling-forms.mdx index 6c968ef3c..9d5ed3410 100644 --- a/docs/src/pages/guide/composition-api/handling-forms.mdx +++ b/docs/src/pages/guide/composition-api/handling-forms.mdx @@ -391,7 +391,7 @@ const { handleSubmit, setFieldError, setErrors } = useForm(); const onSubmit = handleSubmit(async values => { // Send data to the API - const response = await client.post('/users/'); + const response = await client.post('/users/', values); // all good if (!response.errors) { @@ -415,7 +415,7 @@ Alternatively you can use the `FormActions` passed as the second argument to the ```js const onSubmit = handleSubmit(async (values, actions) => { // Send data to the API - const response = await client.post('/users/'); + const response = await client.post('/users/', values); // ... // set single field error @@ -428,3 +428,56 @@ const onSubmit = handleSubmit(async (values, actions) => { actions.setErrors(response.errors); }); ``` + +## Controlled Values + +The form values can be categorized into two categories: + +- Controlled values: values that have a form input controlling them via `useField` or `` or via `useFieldModel` model binding. +- Uncontrolled values: values that are inserted dynamically with `setFieldValue` or inserted initially with initial values. + +Sometimes you maybe only interested in controlled values. For example, your initial data contains noisy extra properties from your API and you wish to ignore them when submitting them back to your API. + +When accessing `values` from `useForm` result or the submission handler you get all the values, both controlled and uncontrolled values. To get access to only the controlled values you can use `controlledValues` from the `useForm` result: + +```vue + + + +``` + +Alternatively for less verbosity, you can create handlers with only the controlled values with `handleSubmit.withControlled` which has the same API as `handleSubmit`: + +```vue + + + +``` diff --git a/packages/vee-validate/src/Form.ts b/packages/vee-validate/src/Form.ts index 61e97b5d4..1925baacc 100644 --- a/packages/vee-validate/src/Form.ts +++ b/packages/vee-validate/src/Form.ts @@ -22,6 +22,7 @@ type FormSlotProps = UnwrapRef< | 'setFieldTouched' | 'setTouched' | 'resetForm' + | 'controlledValues' > > & { handleSubmit: (evt: Event | SubmissionHandler, onSubmit?: SubmissionHandler) => Promise; @@ -80,6 +81,7 @@ const FormImpl = defineComponent({ meta, isSubmitting, submitCount, + controlledValues, validate, validateField, handleReset, @@ -132,6 +134,7 @@ const FormImpl = defineComponent({ values: values, isSubmitting: isSubmitting.value, submitCount: submitCount.value, + controlledValues: controlledValues.value, validate, validateField, handleSubmit: handleScopedSlotSubmit, diff --git a/packages/vee-validate/src/types.ts b/packages/vee-validate/src/types.ts index 5b9496852..90e3bd0f3 100644 --- a/packages/vee-validate/src/types.ts +++ b/packages/vee-validate/src/types.ts @@ -145,6 +145,7 @@ export interface FormValidationResult { export interface SubmissionContext extends FormActions { evt?: Event; + controlledValues: Partial; } export type SubmissionHandler = ( @@ -177,10 +178,16 @@ export type MapValues> = { : Ref; }; +type HandleSubmitFactory = ( + cb: SubmissionHandler, + onSubmitValidationErrorCb?: InvalidSubmissionHandler +) => (e?: Event) => Promise; + export interface PrivateFormContext = Record> extends FormActions { formId: number; values: TValues; + controlledValues: Ref; fieldsByPath: Ref; fieldArrays: PrivateFieldArrayContext[]; submitCount: Ref; @@ -198,10 +205,7 @@ export interface PrivateFormContext = Record unsetInitialValue(path: string): void; register(field: PrivateFieldContext): void; unregister(field: PrivateFieldContext): void; - handleSubmit( - cb: SubmissionHandler, - onSubmitValidationErrorCb?: InvalidSubmissionHandler - ): (e?: Event) => Promise; + handleSubmit: HandleSubmitFactory & { withControlled: HandleSubmitFactory }; setFieldInitialValue(path: string, value: unknown): void; useFieldModel(path: MaybeRef): Ref; useFieldModel(paths: [...MaybeRef[]]): MapValues; diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index 16a73ed62..030333aa4 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -71,6 +71,8 @@ export function useForm = Record { const formId = FORM_COUNTER++; + const controlledModelPaths: Set = new Set(); + // Prevents fields from double resetting their values, which causes checkboxes to toggle their initial value // TODO: This won't be needed if we centralize all the state inside the `form` for form inputs let RESET_LOCK = false; @@ -156,6 +158,15 @@ export function useForm = Record { + return [...controlledModelPaths, ...keysOf(fieldsByPath.value)].reduce((acc, path) => { + const value = getFromPath(formValues, path as string); + setInPath(acc, path as string, value); + + return acc; + }, {} as TValues); + }); + const schema = opts?.validationSchema; /** @@ -221,10 +232,82 @@ export function useForm = Record( + fn?: SubmissionHandler, + onValidationError?: InvalidSubmissionHandler + ) { + return function submissionHandler(e: unknown) { + if (e instanceof Event) { + e.preventDefault(); + e.stopPropagation(); + } + + // Touch all fields + setTouched( + keysOf(fieldsByPath.value).reduce((acc, field) => { + acc[field] = true; + + return acc; + }, {} as Record) + ); + + isSubmitting.value = true; + submitCount.value++; + return validate() + .then(result => { + const values = deepCopy(formValues); + + if (result.valid && typeof fn === 'function') { + const controlled = deepCopy(controlledValues.value); + return fn(onlyControlled ? controlled : values, { + evt: e as Event, + controlledValues: controlled, + setErrors, + setFieldError, + setTouched, + setFieldTouched, + setValues, + setFieldValue, + resetForm, + }); + } + + if (!result.valid && typeof onValidationError === 'function') { + onValidationError({ + values, + evt: e as Event, + errors: result.errors, + results: result.results, + }); + } + }) + .then( + returnVal => { + isSubmitting.value = false; + + return returnVal; + }, + err => { + isSubmitting.value = false; + + // re-throw the err so it doesn't go silent + throw err; + } + ); + }; + }; + } + + const handleSubmitImpl = makeSubmissionFactory(false); + const handleSubmit: typeof handleSubmitImpl & { withControlled: typeof handleSubmitImpl } = handleSubmitImpl as any; + handleSubmit.withControlled = makeSubmissionFactory(true); + const formCtx: PrivateFormContext = { formId, fieldsByPath, values: formValues, + controlledValues, errorBag, errors, schema, @@ -368,6 +451,8 @@ export function useForm = Record = Record( - fn?: SubmissionHandler, - onValidationError?: InvalidSubmissionHandler - ) { - return function submissionHandler(e: unknown) { - if (e instanceof Event) { - e.preventDefault(); - e.stopPropagation(); - } - - // Touch all fields - setTouched( - keysOf(fieldsByPath.value).reduce((acc, field) => { - acc[field] = true; - - return acc; - }, {} as Record) - ); - - isSubmitting.value = true; - submitCount.value++; - return validate() - .then(result => { - if (result.valid && typeof fn === 'function') { - return fn(deepCopy(formValues), { - evt: e as Event, - setErrors, - setFieldError, - setTouched, - setFieldTouched, - setValues, - setFieldValue, - resetForm, - }); - } - - if (!result.valid && typeof onValidationError === 'function') { - onValidationError({ - values: deepCopy(formValues), - evt: e as Event, - errors: result.errors, - results: result.results, - }); - } - }) - .then( - returnVal => { - isSubmitting.value = false; - - return returnVal; - }, - err => { - isSubmitting.value = false; - - // re-throw the err so it doesn't go silent - throw err; - } - ); - }; - } - - function setFieldInitialValue(path: string, value: unknown) { - setInPath(initialValues.value, path, deepCopy(value)); - } - function unsetInitialValue(path: string) { unsetPath(initialValues.value, path); } @@ -710,6 +730,10 @@ export function useForm = Record> { const schemaValue = unref(schema); if (!schemaValue) { diff --git a/packages/vee-validate/tests/useForm.spec.ts b/packages/vee-validate/tests/useForm.spec.ts index cc67a1379..d42ddc4e3 100644 --- a/packages/vee-validate/tests/useForm.spec.ts +++ b/packages/vee-validate/tests/useForm.spec.ts @@ -1,7 +1,7 @@ import { FormContext, useField, useForm } from '@/vee-validate'; import { mountWithHoc, setValue, flushPromises, runInSetup } from './helpers'; import * as yup from 'yup'; -import { Ref } from 'vue'; +import { onMounted, Ref } from 'vue'; describe('useForm()', () => { const REQUIRED_MESSAGE = 'Field is required'; @@ -508,4 +508,116 @@ describe('useForm()', () => { await flushPromises(); expect(error?.textContent).toBe('not b'); }); + + // #3862 + test('exposes controlled only values', async () => { + const spy = jest.fn(); + const initial = { + field: '111', + createdAt: Date.now(), + }; + mountWithHoc({ + setup() { + const { controlledValues, handleSubmit } = useForm({ + initialValues: initial, + }); + + const onSubmit = handleSubmit(values => { + spy({ values, controlled: controlledValues.value }); + }); + + useField('field'); + + onMounted(onSubmit); + + return {}; + }, + template: ` +
+ `, + }); + + await flushPromises(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + values: initial, + controlled: { field: initial.field }, + }) + ); + }); + + // #3862 + test('exposes controlled only via submission handler withControlled', async () => { + const spy = jest.fn(); + const initial = { + field: '111', + createdAt: Date.now(), + }; + mountWithHoc({ + setup() { + const { handleSubmit } = useForm({ + initialValues: initial, + }); + + const onSubmit = handleSubmit.withControlled(values => { + spy({ values }); + }); + + useField('field'); + + onMounted(onSubmit); + + return {}; + }, + template: ` +
+ `, + }); + + await flushPromises(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + values: { field: initial.field }, + }) + ); + }); + + test('useFieldModel marks the field as controlled', async () => { + const spy = jest.fn(); + const initial = { + field: '111', + field2: '222', + createdAt: Date.now(), + }; + mountWithHoc({ + setup() { + const { handleSubmit, useFieldModel } = useForm({ + initialValues: initial, + }); + + const onSubmit = handleSubmit.withControlled(values => { + spy({ values }); + }); + + const fields = useFieldModel(['field', 'field2']); + + onMounted(onSubmit); + + return {}; + }, + template: ` +
+ `, + }); + + await flushPromises(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + values: { field: initial.field, field2: initial.field2 }, + }) + ); + }); });