创建带有可选嵌套值和前缀的react hook表单



我有很多用react-hook-form定义的表单。有时我单独使用它们,有时我把它们结合起来使用。假设我有一个Task表单和一个Note表单。有时我有一个提交任务的表单,有时我有一个提交笔记和任务的表单。我正试图通过使用字符串为输入元素添加前缀来管理各种形式之间的名称冲突。

我想定义一个Task表单,单独使用时作为<TaskForm/>,与其他表单结合使用时作为<TaskForm prefix="task"/>。到目前为止,我有一些工作,但打字是一个非常困难的时间。

假设数据类型为

type TaskFormData = {
title: string;
}

我想使用一个简单的表单,其中没有名称冲突:

export const Form = () => {
const methods = useForm<TaskFormData>({
defaultValues: { title: 'Some input value' }
});
const { handleSubmit } = methods;
const onSubmit = (data: TaskFormData) => {
alert(JSON.stringify(data));
// should be { "title": "input value" }
};
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<TaskForm /> <!-- with a field description -->
</form>
</FormProvider>
);
}

或带前缀的Form,其中我可能会使用各种字段名称冲突的表单。

export const PrefixedForm = () => {
const methods = useForm<{ task: NestedValue<TaskFormData> }>({
defaultValues: { task: { title: 'Some input value' } }
});
const { handleSubmit } = methods;
const onSubmit = (data: { task: TaskFormData }) => {
alert(JSON.stringify(data));
// should be { "task": { "title": "input value" } }
};
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<NoteForm prefix="note" /> <!-- with a field note.description -->
<TaskForm prefix="task" /> <!-- with a field task.description -->
</form>
</FormProvider>
);
};
到目前为止,这是TaskForm 的工作示例。
export const TaskForm = ({ prefix }: { prefix?: string }) => {
const { register } = useFormContext<any>();
return (
<input {...register(prefix ? `${prefix}.title` : 'title')} />
);
};

useFormContext<any>真的让我很为难。我希望将类型信息保留在每个表单中。但是似乎不能得到正确的打字稿。

经过大量的研究,我真的认为这是可行的:

export type PrefixedTaskFormData<P extends string> = {
[K in keyof TaskFormData as `${P}.K`]: TaskFormData[K]
}
export const TaskForm = <P extends string>({ prefix }: { prefix?: P }) => {
const { register } = useFormContext<TaskFormData | PrefixedTaskFormData<P>>();
return (
<input {...register(prefix ? `${prefix}.title` : 'title')} />
);
};

也试过

export type PrefixedTaskFormData<P extends string> = {
[K in P]: NestedValue<TaskFormData>
}
export const TaskForm = <P extends string>({ prefix }: { prefix?: P }) => {
const { register } = useFormContext<TaskFormData | PrefixedTaskFormData<P>>();
return (
<input {...register(prefix ? `${prefix}.title` : 'title')} />
);
};

任何想法?

这是我目前的解决方案,使用高阶组件:

https://codesandbox.io/s/react-hook-form-watch-v7-ts-forked-b84nri?file=/src/index.tsx

为了方便参考,我粘贴了下面的一些代码。参见withFormHOC。

import { FieldError, Path, UseFormReturn } from 'react-hook-form';
import { get } from 'lodash';
import React, { ComponentType } from 'react';
/**
* Props for components wrapped with {@link withForm} should extend this interface.
*
* Do not pass in a generic, since you don't know what the parent form value will be.
*
* @example
* export interface MyComponentProps extends FormProps {
*   myCustomProps: string;
* }
*/
// Deliberately using `any` since we don't know what the parent form will look like.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface NestedFormProps<ParentComponentFormValue = any> {
form: UseFormReturn<ParentComponentFormValue>;
formPath?: Path<ParentComponentFormValue>;
disabled?: boolean;
}
/**
* A nested form component doesn't know what the parent form looks like, so it
* needs to use the `formPath` passed down to it. It's easy to forget to use this,
* however, so we nest the form value of the component in a key called `unknownPath`.
* This makes it obvious that there's an issue when you try to pass in a literal
* string without prepending the provided formPath, since TS will provide autocomplete
* options prepended with `unknownPath`.
*
* To get the proper path, use the `getFormPath` function that's given to you by
* {@link withForm}. You can also use {@link createGetFormPath}, but the {@link withForm}
* is preferred.
*/
export interface NestedFormValue<Value> {
unknownPath: Value;
}
/**
* A higher-order component, designed for nested form components.
*
* [CodeSandbox Example](https://codesandbox.io/s/react-hook-form-watch-v7-ts-forked-b84nri?file=/src/index.tsx)
*
* @example
* ```tsx
* // App.tsx
* import { PersonForm, PersonFormValue } from "./PersonForm";
*
* function App() {
*   const form = useForm<PersonFormValue>();
*
*   return (
*     <>
*       <PersonForm form={form} msg="Nested Forms Example" />
*     </>
*   );
* }
*
* // PersonForm.tsx
* export interface PersonFormProps extends NestedFormProps {
*   msg: string;
* }
*
* export interface PersonFormValue {
*   name: NameFormValue;
* }
*
* export const PersonForm = withForm<PersonFormValue, PersonFormProps>(
* ({ form, getFormPath, msg }) => {
*   return (
*     <>
*       <h3>{msg}</h3>
*       <NameForm form={form} formPath={getFormPath("name")} />
*     </>
*   );
* });
*
* // NameForm.tsx
* export const NameForm = withForm<NameFormValue, NameFormProps>(
* ({ form, getFormPath }) => {
*   return (
*     <>
*       <label>First</label>
*       <input {...form.register(getFormPath("first"))} />
*       <label>Last</label>
*       <input {...form.register(getFormPath("last"))} />
*     </>
*   );
* });
* ```
*/
export function withForm<Value, Props extends NestedFormProps>(
WrappedComponent: ComponentType<NestedFormInternalProps<Value, Props>>
) {
function Component<ParentValue>({ form, formPath, ...props }: Omit<Props, keyof NestedFormProps> & NestedFormProps<ParentValue>) {
return <WrappedComponent {...getNestedFormInternalProps<Value>(form, formPath)} {...props} />;
}
// for React Dev Tools
Component.displayName = `withTheme(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
return Component;
}
/** The props that are passed to a component wrapped with {@link withForm} */
export type NestedFormInternalProps<Value, Props> = ReturnType<NestedFormInternalPropsHelper<Value>['get']> &
Omit<Props, 'form' | 'formPath'>;
/** Used internally by {@link withForm} */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getNestedFormInternalProps<Value>(form: UseFormReturn<Value>, formPath?: Path<Value>) {
function getFormPath(): 'unknownPath';
function getFormPath<FormPath extends Path<Value>>(path: FormPath): `unknownPath.${FormPath}`;
function getFormPath<FormPath extends Path<Value>>(path?: FormPath) {
return path ? (concatPaths(formPath, path) as `unknownPath.${FormPath}`) : (formPath as 'unknownPath');
}
function getErrors(): typeof form.formState.errors;
function getErrors(path: Path<Value>): FieldError;
function getErrors(path?: Path<Value>) {
return path ? (get(form.formState.errors, getFormPath(path)) as FieldError) : form.formState.errors;
}
return {
form: (form as unknown) as UseFormReturn<NestedFormValue<Value>>,
getFormPath,
getErrors,
};
}
/**
* Used to join parent form paths with child form paths.
*
* @example
* ```ts
* concatPaths('a.', '.b.', '3, 'd.'); // returns 'a.b.3.d'
* ```
*/
export function concatPaths(...paths: (string | number | undefined | null)[]): string {
return paths
.join('.') // add . between each path
.replace(/.{2,}/g, '.') // replace .. with .
.replace(/^./, '') // remove leading .
.replace(/.$/, ''); // remove trailing .
}
/**
* @private Only exists to allow passing a generic to `ReturnType`
*
* Can be removed once instantiation expressions are available
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-7-beta/#instantiation-expressions
*/
class NestedFormInternalPropsHelper<Value> {
// has no explicit return type so we can infer it
get(form: UseFormReturn<Value>, formPath?: Path<Value>) {
return getNestedFormInternalProps(form, formPath);
}
}

不好看,但很好用。

看起来在库本身包含解决方案方面有一些进展。

最新更新