Strongly type form components

Strongly type form components
This commit is contained in:
Timothy Armes
2023-10-10 15:19:01 +02:00
committed by Owen Rees
parent 3203c8af58
commit f2a245ba6d
14 changed files with 260 additions and 88 deletions
@@ -74,17 +74,24 @@ const TournamentForm = () => {
<div className="col-span-4">
<TextField
name="tournament.tournament"
control={form.control}
label={t("tournamentNameLabel")}
placeholder={t("tournamentNamePlaceholder")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<DateField name="tournament.date" label={t("dateLabel")} required />
<DateField
name="tournament.date"
control={form.control}
label={t("dateLabel")}
required
/>
</div>
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.url"
control={form.control}
label={t("urlLabel")}
placeholder={t("urlPlaceholder")}
required
@@ -93,6 +100,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<SelectField
name="tournament.time_control"
control={form.control}
label={t("tcLabel")}
options={[
TimeControl.Classic,
@@ -108,12 +116,14 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<SwitchField
name="tournament.norm_tournament"
control={form.control}
label={t("normLabel")}
/>
</div>
<div className="col-span-4">
<TextField
name="tournament.address"
control={form.control}
label={t("addressLabel")}
placeholder={t("addressPlaceholder")}
required
@@ -122,6 +132,7 @@ const TournamentForm = () => {
<div className="col-span-4 sm:col-span-2">
<TextField
name="tournament.town"
control={form.control}
label={t("townLabel")}
placeholder={t("townPlaceholder")}
required
@@ -130,6 +141,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.department"
control={form.control}
label={t("departmentLabel")}
placeholder={t("departmentPlaceholder")}
required
@@ -138,6 +150,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.country"
control={form.control}
label={t("countryLabel")}
placeholder={t("countryPlaceholder")}
required
@@ -146,6 +159,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="name"
control={form.control}
label={t("yourNameLabel")}
placeholder={t("yourNamePlaceholder")}
required
@@ -154,6 +168,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="email"
control={form.control}
label={t("yourEmailLabel")}
placeholder={t("yourEmailPlaceholder")}
required
@@ -162,6 +177,7 @@ const TournamentForm = () => {
<div className="col-span-4 row-span-2 sm:col-span-2">
<TextAreaField
name="message"
control={form.control}
label={t("messageLabel")}
rows={6}
placeholder={t("messagePlaceholder")}
@@ -171,6 +187,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.coordinates.0"
control={form.control}
label={t("latLabel")}
type="number"
required
@@ -179,6 +196,7 @@ const TournamentForm = () => {
<div className="col-span-2 sm:col-span-1">
<TextField
name="tournament.coordinates.1"
control={form.control}
label={t("lngLabel")}
type="number"
required
@@ -55,6 +55,7 @@ const ContactForm = () => {
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<TextField
name="email"
control={form.control}
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
required
@@ -62,6 +63,7 @@ const ContactForm = () => {
<TextField
name="subject"
control={form.control}
label={t("subjectLabel")}
placeholder={t("subjectPlaceholder")}
required
@@ -69,6 +71,7 @@ const ContactForm = () => {
<TextAreaField
name="message"
control={form.control}
rows={6}
label={t("messageLabel")}
placeholder={t("messagePlaceholder")}
+4
View File
@@ -107,6 +107,7 @@ export const ManualEloForm = () => {
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
name="currentElo"
control={form.control}
label={t("currentEloLabel")}
placeholder={t("currentEloPlaceholder")}
type="number"
@@ -115,6 +116,7 @@ export const ManualEloForm = () => {
<SelectField
name="kFactor"
control={form.control}
label={t("yourKFactorLabel")}
options={["40", "20", "10"].map((k) => ({
value: k,
@@ -139,6 +141,7 @@ export const ManualEloForm = () => {
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
name={`games.${i}.opponentElo`}
control={form.control}
placeholder={t("opponentEloPlaceholder")}
type="number"
required
@@ -147,6 +150,7 @@ export const ManualEloForm = () => {
<div className="flex items-center space-x-2">
<RadioGroupField
name={`games.${i}.result`}
control={form.control}
options={["win", "draw", "loss"].map((result) => ({
value: result,
label: t("gameResult", { result }),
+3
View File
@@ -185,6 +185,7 @@ export default function Elo() {
<div className="flex items-end gap-4">
<TextField
name="url"
control={form.control}
label={t("resultsUrlLabel")}
placeholder={t("resultsUrlLabel")}
/>
@@ -222,6 +223,7 @@ export default function Elo() {
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<SelectField
name="player"
control={form.control}
required
label={t("choosePlayerLabel")}
placeholder={t("choosePlayerPlaceholder")}
@@ -231,6 +233,7 @@ export default function Elo() {
<SelectField
name="kFactor"
control={form.control}
label={t("kFactorLabel")}
options={["40", "20", "10"].map((k) => ({
value: k,
+32 -15
View File
@@ -6,26 +6,41 @@ import { get, range } from "lodash";
import { useLocale, useTranslations } from "next-intl";
import DatePicker from "react-datepicker";
import { registerLocale } from "react-datepicker";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "../Field";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "../Field";
import { InputDatePicker } from "./components";
import { DatePickerCustomHeader } from "./components/DatePickerCustomHeader";
registerLocale("fr", fr);
type DateFieldProps = FieldProps & {
maxDate?: Date;
minDate?: Date;
dateFormat?: string;
className?: string;
datePickerPopperClass?: string;
required?: boolean;
};
type DateFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
maxDate?: Date;
minDate?: Date;
dateFormat?: string;
className?: string;
datePickerPopperClass?: string;
required?: boolean;
}
>;
export const DateField = ({
export const DateField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
minDate,
maxDate,
dateFormat = "dd/MM/yyyy",
@@ -33,15 +48,17 @@ export const DateField = ({
datePickerPopperClass,
name,
control,
required,
...otherFieldProps
}: DateFieldProps) => {
}: DateFieldProps<TFieldValues, TFieldName>) => {
const locale = useLocale();
const at = useTranslations("App");
const min = minDate ? minDate.getFullYear() : 1900;
const inputRef = useRef(null);
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
@@ -49,10 +66,10 @@ export const DateField = ({
const hasError = name && !!get(errors, name)?.message;
return (
<Field name={name} {...otherFieldProps}>
<Field name={name} control={control} {...otherFieldProps}>
<div className={twMerge("relative flex w-full flex-col", className)}>
<Controller
control={form.control}
control={control}
name={name}
render={({ field }) => (
<DatePicker
+31 -8
View File
@@ -1,14 +1,25 @@
import { ReactNode } from "react";
import { get } from "lodash";
import { useFormContext } from "react-hook-form";
import {
Control,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Prettify } from "@/types";
import { ErrorMessage } from "./ErrorMessage";
import { Label } from "./Label";
export type FieldProps = {
name: string;
export type GenericFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TFieldName;
control: Control<TFieldValues>;
className?: string;
labelClassName?: string;
childrenWrapperClassName?: string;
@@ -16,12 +27,21 @@ export type FieldProps = {
hideErrorMessage?: boolean;
};
export const Field = (
props: Omit<FieldProps, "name"> & {
type FieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
name?: string;
children: ReactNode;
},
}
>;
export const Field = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: FieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
@@ -32,9 +52,12 @@ export const Field = (
children,
required,
hideErrorMessage = false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used to restrict the `name` prop type
control,
} = props;
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
+12 -8
View File
@@ -1,13 +1,17 @@
import { twMerge } from "tailwind-merge";
type LabelProps = React.DetailedHTMLProps<
React.LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
> & {
required?: boolean;
className?: string;
tooltip?: React.ReactNode;
};
import { Prettify } from "@/types";
type LabelProps = Prettify<
React.DetailedHTMLProps<
React.LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
> & {
required?: boolean;
className?: string;
tooltip?: React.ReactNode;
}
>;
export const Label = ({
required,
+32 -13
View File
@@ -1,12 +1,19 @@
import React from "react";
import { RadioGroup } from "@headlessui/react";
import { get, isNil } from "lodash";
import { isNil } from "lodash";
import { useTranslations } from "next-intl";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "./Field";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
@@ -15,13 +22,26 @@ export type BaseOption<T = string, D = unknown> = {
data?: D;
};
export type RadioGroupFieldProps<T = string, D = unknown> = FieldProps & {
required?: boolean;
options: BaseOption<T, D>[];
};
export type RadioGroupFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T = string,
D = unknown,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> & {
required?: boolean;
options: BaseOption<T, D>[];
}
>;
export const RadioGroupField = <T extends React.Key = string, D = unknown>({
export const RadioGroupField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
T extends React.Key = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
@@ -30,16 +50,14 @@ export const RadioGroupField = <T extends React.Key = string, D = unknown>({
required,
options,
}: RadioGroupFieldProps<T, D>) => {
}: RadioGroupFieldProps<TFieldValues, TFieldName, T, D>) => {
const t = useTranslations("App");
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
} = form;
const hasError = !!get(errors, name)?.message;
const valueToOption = (value: T): BaseOption<T, D> =>
options.find((o) => o.value === value) ?? { value, label: "" };
@@ -47,6 +65,7 @@ export const RadioGroupField = <T extends React.Key = string, D = unknown>({
<Field
{...{
name,
control,
className,
label,
labelClassName,
@@ -57,7 +76,7 @@ export const RadioGroupField = <T extends React.Key = string, D = unknown>({
>
<Controller
name={name}
control={form.control}
control={control}
render={({ field: { onChange, value } }) => {
const optionValue = isNil(value) ? null : valueToOption(value);
+30 -17
View File
@@ -2,9 +2,13 @@ import React from "react";
import { flatMap, get, isArray, isNil, isObject } from "lodash";
import { useTranslations } from "next-intl";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import Select, {
ActionMeta,
ClassNamesConfig,
GroupBase,
OnChangeValue,
@@ -12,7 +16,9 @@ import Select, {
} from "react-select";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "./Field";
import { Prettify } from "@/types";
import { Field, GenericFieldProps } from "./Field";
export type BaseOption<T = string, D = unknown> = {
value: T;
@@ -54,7 +60,7 @@ export const classNames = <Option, IsMulti extends boolean = false>(
"dark:border-gray-600 dark:bg-gray-700 dark:text-white",
),
groupHeading: () => "ml-3 mt-2 mb-1 text-textGray text-sm uppercase",
option: ({ isFocused, isSelected, isDisabled }) =>
option: ({ isFocused, isDisabled }) =>
twMerge(
"px-3 py-2 hover:cursor-pointer",
isDisabled && "opacity-50",
@@ -65,23 +71,30 @@ export const classNames = <Option, IsMulti extends boolean = false>(
});
export type SelectFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
> = FieldProps &
Omit<
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
"onChange" | "value" | "classNames"
> & {
required?: boolean;
};
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
"onChange" | "value" | "classNames" | "name"
> & {
required?: boolean;
}
>;
export const SelectField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
IsMulti extends boolean = false,
T = string,
D = unknown,
>({
name,
control,
className,
labelClassName,
childrenWrapperClassName,
@@ -92,9 +105,9 @@ export const SelectField = <
placeholder,
options,
...selectProps
}: SelectFieldProps<IsMulti, T, D>) => {
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
const t = useTranslations("App");
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
@@ -118,6 +131,7 @@ export const SelectField = <
<Field
{...{
name,
control,
className,
label,
labelClassName,
@@ -128,11 +142,10 @@ export const SelectField = <
>
<Controller
name={name}
control={form.control}
control={control}
render={({ field: { onChange, value } }) => {
const onSelectChange = (
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
actionMeta: ActionMeta<BaseOption<T, D>>,
) => {
if (isNil(newValue)) onChange(null);
else if (isArray(newValue)) {
@@ -143,12 +156,12 @@ export const SelectField = <
const optionValue = isNil(value)
? null
: isArray(value)
? value.map(valueToOption)
? // @ts-ignore - this is too complex for TS to understand
value.map(valueToOption)
: valueToOption(value);
return (
<Select
isClearable
value={optionValue}
onChange={onSelectChange}
options={options}
+28 -6
View File
@@ -1,14 +1,35 @@
import React from "react";
import { Switch, SwitchProps } from "@headlessui/react";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "./Field";
import { Prettify } from "@/types";
export const SwitchField = (props: FieldProps & SwitchProps<"button">) => {
import { Field, GenericFieldProps } from "./Field";
type SwitchFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<SwitchProps<"button">, "name">
>;
export const SwitchField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: SwitchFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
disabled,
className,
@@ -19,12 +40,13 @@ export const SwitchField = (props: FieldProps & SwitchProps<"button">) => {
...rest
} = props;
const form = useFormContext();
const form = useFormContext<TFieldValues>();
return (
<Field
{...{
name,
control,
type: "checkbox",
label,
disabled,
@@ -34,7 +56,7 @@ export const SwitchField = (props: FieldProps & SwitchProps<"button">) => {
>
<div className="flex w-full flex-col">
<Controller
control={form.control}
control={control}
name={name}
render={({ field }) => (
<div className="flex h-[40px] items-center">
@@ -49,7 +71,7 @@ export const SwitchField = (props: FieldProps & SwitchProps<"button">) => {
)}
{...rest}
>
<span className="ui-checked:translate-x-[19px] ui-not-checked:translate-x-[3px] inline-block h-[14px] w-[14px] transform rounded-full bg-white transition-transform" />
<span className="inline-block h-[14px] w-[14px] transform rounded-full bg-white transition-transform ui-checked:translate-x-[19px] ui-not-checked:translate-x-[3px]" />
</Switch>
</div>
)}
+30 -11
View File
@@ -1,23 +1,40 @@
import React from "react";
import { get } from "lodash";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "./Field";
import { Prettify } from "@/types";
export const TextAreaField = (
props: FieldProps &
React.HTMLProps<HTMLTextAreaElement> & {
import { Field, GenericFieldProps } from "./Field";
type TextAreaFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLTextAreaElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
},
required?: boolean;
}
>;
export const TextAreaField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextAreaFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
label,
required,
disabled,
className,
labelClassName,
childrenWrapperClassName,
@@ -25,10 +42,12 @@ export const TextAreaField = (
startIcon,
handleChanged,
required,
disabled,
...rest
} = props;
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
@@ -40,8 +59,8 @@ export const TextAreaField = (
<Field
{...{
name,
control,
label,
required,
className,
labelClassName,
childrenWrapperClassName,
@@ -50,7 +69,7 @@ export const TextAreaField = (
>
<div className="flex w-full flex-col">
<Controller
control={form.control}
control={control}
name={name}
render={({ field }) => (
<div className="relative">
+27 -8
View File
@@ -1,20 +1,38 @@
import React from "react";
import { get } from "lodash";
import { Controller, useFormContext } from "react-hook-form";
import {
Controller,
FieldPath,
FieldValues,
useFormContext,
} from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Field, FieldProps } from "./Field";
import { Prettify } from "@/types";
export const TextField = (
props: FieldProps &
React.HTMLProps<HTMLInputElement> & {
import { Field, GenericFieldProps } from "./Field";
type TextFieldProps<
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Prettify<
GenericFieldProps<TFieldValues, TFieldName> &
Omit<React.HTMLProps<HTMLInputElement>, "ref" | "name"> & {
handleChanged?: (props: { name: string }) => void;
startIcon?: React.ReactNode;
},
}
>;
export const TextField = <
TFieldValues extends FieldValues = FieldValues,
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(
props: TextFieldProps<TFieldValues, TFieldName>,
) => {
const {
name,
control,
type = "text",
label,
required,
@@ -29,7 +47,7 @@ export const TextField = (
...rest
} = props;
const form = useFormContext();
const form = useFormContext<TFieldValues>();
const {
formState: { errors },
@@ -57,6 +75,7 @@ export const TextField = (
<Field
{...{
name,
control,
label,
required,
className,
@@ -67,7 +86,7 @@ export const TextField = (
>
<div className="flex w-full flex-col">
<Controller
control={form.control}
control={control}
name={name}
render={({ field }) => (
<div className="relative">
+1 -1
View File
@@ -151,7 +151,7 @@
"resultsTitle": "Résultats",
"opponentEloPlaceholder": "Classement de l'adversaire",
"gameResultPlaceholder": "Résultat...",
"gameResult": "{result, select, win {Victoire} draw {Match nul} loss {Défaite} other {{result}}}",
"gameResult": "{result, select, win {Victoire} draw {Nul} loss {Défaite} other {{result}}}",
"addGameButton": "Ajouter un autre résultat de partie",
"resultsUrlLabel": "Collez un lien vers la page de résultats de la FFE",
+8
View File
@@ -67,3 +67,11 @@ export type ResponseMessage = {
};
export type ScrollableElement = Window | HTMLElement;
// Prettify takes a type as its argument and returns a new type that has the same properties as the original type,
// but the properties are not intersected. This means that the new type is easier to read and understand.
// https://gist.github.com/palashmon/db68706d4f26d2dbf187e76409905399
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};