Skip to content

Commit

Permalink
Merge pull request #1339 from Tampere/feature/form-improvement
Browse files Browse the repository at this point in the history
Fix and refactor form validation
  • Loading branch information
mmoila authored Oct 11, 2024
2 parents 0af3bd8 + cc589d0 commit b4a886b
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 185 deletions.
50 changes: 50 additions & 0 deletions frontend/src/components/forms/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { css } from '@emotion/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormControl, FormLabel, Typography } from '@mui/material';
import { Dayjs } from 'dayjs';
import React, { PropsWithChildren, useMemo } from 'react';
import {
Controller,
ControllerRenderProps,
FieldError,
FieldErrors,
FieldName,
FieldValues,
ResolverOptions,
useFormContext,
} from 'react-hook-form';
import { ZodObject, ZodType, objectUtil } from 'zod';

import { useTranslations } from '@frontend/stores/lang';
import { getRequiredFields } from '@frontend/utils/form';

import { FormErrors, mergeErrors } from '@shared/formerror';
import { isTranslationKey } from '@shared/language';

import { HelpTooltip } from '../HelpTooltip';
Expand Down Expand Up @@ -179,3 +186,46 @@ export function getDateFieldErrorMessage(
}
return fallBackMessage;
}

export function getFormValidator<T extends FieldValues>(
schemaValidation: ReturnType<typeof zodResolver>,
getServerErrors: (values: T) => Promise<FormErrors<T>>,
) {
return async function formValidation(
values: T,
context: {
getRequiredFields: typeof getRequiredFields;
getErrors: () => FieldErrors;
},
options: ResolverOptions<T>,
) {
const fields = options.names ?? [];
const currentErrors = context.getErrors();

const needsDateValidation =
fields.includes('startDate' as FieldName<T>) || fields.includes('endDate' as FieldName<T>);
const isFormValidation = (fields && needsDateValidation) || fields.length > 1;

const currentDateErrors =
!isFormValidation && (currentErrors.startDate || currentErrors.endDate)
? {
errors: {
...(currentErrors.starDate && { startDate: currentErrors.startDate }),
...(currentErrors.endDate && { startDate: currentErrors.endDate }),
},
}
: null;

const serverErrors = isFormValidation
? getServerErrors({ ...values, geom: undefined, geometryDump: undefined }).catch(() => null)
: null;
const shapeErrors = schemaValidation(values, context, options);
const errors = await Promise.all([serverErrors, shapeErrors, currentDateErrors]);
// TODO fix typing here to drop the mapping below
const formattedErrors: FormErrors<T>[] = errors.map((error) => error as FormErrors<T>);
return {
values,
errors: mergeErrors(formattedErrors).errors,
};
};
}
37 changes: 12 additions & 25 deletions frontend/src/views/DetailplanProject/DetailplanProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useAtomValue, useSetAtom } from 'jotai';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { FormProvider, ResolverOptions, useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';

import { trpc } from '@frontend/client';
import { ConfirmDialog } from '@frontend/components/dialogs/ConfirmDialog';
import { FormDatePicker, FormField, getDateFieldErrorMessage } from '@frontend/components/forms';
import {
FormDatePicker,
FormField,
getDateFieldErrorMessage,
getFormValidator,
} from '@frontend/components/forms';
import { CodeSelect } from '@frontend/components/forms/CodeSelect';
import { SapProjectIdField } from '@frontend/components/forms/SapProjectIdField';
import { UserSelect } from '@frontend/components/forms/UserSelect';
Expand All @@ -21,7 +26,6 @@ import { useNavigationBlocker } from '@frontend/stores/navigationBlocker';
import { dirtyAndValidFieldsAtom, projectEditingAtom } from '@frontend/stores/projectView';
import { getRequiredFields } from '@frontend/utils/form';

import { mergeErrors } from '@shared/formerror';
import {
DbDetailplanProject,
DetailplanProject,
Expand Down Expand Up @@ -113,35 +117,18 @@ export const DetailplanProjectForm = forwardRef(function DetailplanProjectForm(
const { detailplanProject, user, sap } = trpc.useUtils();
const formValidator = useMemo(() => {
const schemaValidation = zodResolver(detailplanProjectSchema);

return async function formValidation(
values: DbDetailplanProject,
context: any,
options: ResolverOptions<DbDetailplanProject>,
) {
const fields = options.names ?? [];

const needsDateValidation = fields.includes('startDate') || fields.includes('endDate');

const isFormValidation = (fields && needsDateValidation) || fields.length > 1;

const serverErrors = isFormValidation
? detailplanProject.upsertValidate.fetch(values).catch(() => null)
: null;
const shapeErrors = schemaValidation(values, context, options);
const errors = await Promise.all([serverErrors, shapeErrors]);
return {
values,
errors: mergeErrors(errors).errors,
};
};
return getFormValidator<DbDetailplanProject>(
schemaValidation,
detailplanProject.upsertValidate.fetch,
);
}, []);

const form = useForm<DbDetailplanProject>({
mode: 'all',
resolver: formValidator,
context: {
requiredFields: getRequiredFields(detailplanProjectSchema),
getErrors: () => form.formState.errors,
},
defaultValues: props.project ?? formDefaultValues,
});
Expand Down
47 changes: 12 additions & 35 deletions frontend/src/views/MaintenanceProject/MaintenanceProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { FormProvider, ResolverOptions, useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';

import { trpc } from '@frontend/client';
import { ConfirmDialog } from '@frontend/components/dialogs/ConfirmDialog';
import { FormDatePicker, FormField, getDateFieldErrorMessage } from '@frontend/components/forms';
import {
FormDatePicker,
FormField,
getDateFieldErrorMessage,
getFormValidator,
} from '@frontend/components/forms';
import { CodeSelect } from '@frontend/components/forms/CodeSelect';
import { FormCheckBox } from '@frontend/components/forms/FormCheckBox';
import { SapProjectIdField } from '@frontend/components/forms/SapProjectIdField';
Expand All @@ -23,7 +28,6 @@ import { dirtyAndValidFieldsAtom, projectEditingAtom } from '@frontend/stores/pr
import { getRequiredFields } from '@frontend/utils/form';
import { ProjectOwnerChangeDialog } from '@frontend/views/Project/ProjectOwnerChangeDialog';

import { mergeErrors } from '@shared/formerror';
import {
DbMaintenanceProject,
MaintenanceProject,
Expand Down Expand Up @@ -110,45 +114,18 @@ export const MaintenanceProjectForm = forwardRef(function MaintenanceProjectForm
const { maintenanceProject } = trpc.useUtils();
const formValidator = useMemo(() => {
const schemaValidation = zodResolver(maintenanceProjectSchema);

return async function formValidation(
values: MaintenanceProject,
context: any,
options: ResolverOptions<MaintenanceProject>,
) {
const fields = options.names ?? [];
const currentErrors = context.getErrors();
const needsDateValidation =
Boolean(props.project && (currentErrors.startDate || currentErrors.endDate)) ||
fields.includes('startDate') ||
fields.includes('endDate');

const isFormValidation = (fields && needsDateValidation) || fields.length > 1;

const serverErrors = isFormValidation
? maintenanceProject.upsertValidate
.fetch({ ...values, geom: undefined, geometryDump: undefined })
.catch(() => null)
: null;
const shapeErrors = schemaValidation(values, context, options);
const errors = await Promise.all([serverErrors, shapeErrors]);
return {
values,
errors: mergeErrors(errors).errors,
};
};
return getFormValidator<MaintenanceProject>(
schemaValidation,
maintenanceProject.upsertValidate.fetch,
);
}, []);

function getErrors() {
return form.formState.errors;
}

const form = useForm<MaintenanceProject>({
mode: 'all',
resolver: formValidator,
context: {
requiredFields: getRequiredFields(maintenanceProjectSchema),
getErrors,
getErrors: () => form.formState.errors,
},
defaultValues: props.project
? {
Expand Down
63 changes: 18 additions & 45 deletions frontend/src/views/Project/InvestmentProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { FormProvider, ResolverOptions, useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';

import { trpc } from '@frontend/client';
import { ConfirmDialog } from '@frontend/components/dialogs/ConfirmDialog';
import { FormDatePicker, FormField, getDateFieldErrorMessage } from '@frontend/components/forms';
import {
FormDatePicker,
FormField,
getDateFieldErrorMessage,
getFormValidator,
} from '@frontend/components/forms';
import { CodeSelect } from '@frontend/components/forms/CodeSelect';
import { SapProjectIdField } from '@frontend/components/forms/SapProjectIdField';
import { UserSelect } from '@frontend/components/forms/UserSelect';
Expand All @@ -21,7 +26,6 @@ import { useNavigationBlocker } from '@frontend/stores/navigationBlocker';
import { dirtyAndValidFieldsAtom, projectEditingAtom } from '@frontend/stores/projectView';
import { getRequiredFields } from '@frontend/utils/form';

import { mergeErrors } from '@shared/formerror';
import {
DbInvestmentProject,
InvestmentProject,
Expand Down Expand Up @@ -108,54 +112,20 @@ export const InvestmentProjectForm = forwardRef(function InvestmentProjectForm(

const { investmentProject } = trpc.useUtils();
const formValidator = useMemo(() => {
const schemaValidation = zodResolver(
investmentProjectSchema.refine(
(project) => new Date(project.startDate) < new Date(project.endDate),
{ message: tr('project.error.endDateBeforeStartDate') },
),
);
const schemaValidation = zodResolver(investmentProjectSchema);

return async function formValidation(
values: InvestmentProject,
context: any,
options: ResolverOptions<InvestmentProject>,
) {
const fields = options.names ?? [];

const currentErrors = context.getErrors();

const needsDateValidation =
Boolean(props.project && (currentErrors.startDate || currentErrors.endDate)) ||
fields.includes('startDate') ||
fields.includes('endDate');

const isFormValidation = (fields && needsDateValidation) || fields.length > 1;

const serverErrors = isFormValidation
? investmentProject.upsertValidate
.fetch({ ...values, geom: undefined, geometryDump: undefined })
.catch(() => null)
: null;
const shapeErrors = schemaValidation(values, context, options);
const errors = await Promise.all([serverErrors, shapeErrors]);

return {
values,
errors: mergeErrors(errors).errors,
};
};
return getFormValidator<InvestmentProject>(
schemaValidation,
investmentProject.upsertValidate.fetch,
);
}, []);

function getErrors() {
return form.formState.errors;
}

const form = useForm<InvestmentProject>({
mode: 'all',
mode: 'onChange',
resolver: formValidator,
context: {
requiredFields: getRequiredFields(investmentProjectSchema),
getErrors,
getErrors: () => form.formState.errors,
},
defaultValues: props.project ?? formDefaultValues,
});
Expand Down Expand Up @@ -187,7 +157,10 @@ export const InvestmentProjectForm = forwardRef(function InvestmentProjectForm(

useEffect(() => {
if (!props.project) {
setDirtyAndValidViews((prev) => ({ ...prev, form: { isDirty, isValid } }));
setDirtyAndValidViews((prev) => ({
...prev,
form: { isDirty, isValid: isValid },
}));
} else {
setDirtyAndValidViews((prev) => ({
...prev,
Expand Down
Loading

0 comments on commit b4a886b

Please sign in to comment.