From 7a4a9c89bde69f734c59e323f07d2cfade9a625e Mon Sep 17 00:00:00 2001 From: Chris Bedwell Date: Fri, 3 May 2024 17:30:54 +0100 Subject: [PATCH 01/59] feat: zod validation set-up --- package.json | 4 +- .../FormComponents/CheckJobName.tsx | 13 +- .../FormComponents/HttpCheckBearerToken.tsx | 2 +- .../FormComponents/HttpCheckSSLOptions.tsx | 13 +- .../FormComponents/RequestHeaders.tsx | 19 +- src/components/CheckEditor/ProbeOptions.tsx | 10 +- src/components/CheckForm/CheckForm.tsx | 8 +- src/components/LabelField/LabelField.tsx | 20 ++- .../NameValueInput/NameValueInput.tsx | 153 ++++++++-------- .../OptionalInput/OptionalInput.tsx | 7 +- src/components/TLSConfig.tsx | 21 +-- src/components/constants.ts | 10 +- src/schemas/forms/BaseCheckSchema.ts | 19 ++ src/schemas/forms/HttpCheckSchema.ts | 59 ++++++ src/schemas/general/Frequency.ts | 8 + src/schemas/general/HttpTarget.ts | 63 +++++++ src/schemas/general/Job.ts | 8 + src/schemas/general/Label.ts | 36 ++++ src/schemas/general/Probes.ts | 17 ++ src/schemas/general/TLSConfig.ts | 58 ++++++ src/schemas/general/Target.ts | 7 + src/types.ts | 2 +- src/validation.test.ts | 21 +-- src/validation.ts | 169 +----------------- yarn.lock | 10 ++ 25 files changed, 440 insertions(+), 317 deletions(-) create mode 100644 src/schemas/forms/BaseCheckSchema.ts create mode 100644 src/schemas/forms/HttpCheckSchema.ts create mode 100644 src/schemas/general/Frequency.ts create mode 100644 src/schemas/general/HttpTarget.ts create mode 100644 src/schemas/general/Job.ts create mode 100644 src/schemas/general/Label.ts create mode 100644 src/schemas/general/Probes.ts create mode 100644 src/schemas/general/TLSConfig.ts create mode 100644 src/schemas/general/Target.ts diff --git a/package.json b/package.json index 10e67ddde..e7fdf2f6d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@grafana/scenes": "3.5.0", "@grafana/schema": "10.1.4", "@grafana/ui": "10.1.4", + "@hookform/resolvers": "3.3.4", "@popperjs/core": "^2.9.2", "@tanstack/react-query": "5.8.4", "@tanstack/react-query-devtools": "5.8.4", @@ -128,7 +129,8 @@ "react-router-dom": "^5.2.0", "sortablejs": "^1.15.0", "valid-url": "^1.0.9", - "yaml": "^2.2.2" + "yaml": "^2.2.2", + "zod": "3.23.6" }, "packageManager": "yarn@1.22.19" } diff --git a/src/components/CheckEditor/FormComponents/CheckJobName.tsx b/src/components/CheckEditor/FormComponents/CheckJobName.tsx index 6e5bd6d34..eef9f038c 100644 --- a/src/components/CheckEditor/FormComponents/CheckJobName.tsx +++ b/src/components/CheckEditor/FormComponents/CheckJobName.tsx @@ -21,10 +21,7 @@ export const CheckJobName = () => { > { ); }; - -function validateJob(job: string): string | undefined { - if (job.length > 128) { - return 'Job name must be 128 characters or less'; - } - - return undefined; -} diff --git a/src/components/CheckEditor/FormComponents/HttpCheckBearerToken.tsx b/src/components/CheckEditor/FormComponents/HttpCheckBearerToken.tsx index e603ab726..821c6ba74 100644 --- a/src/components/CheckEditor/FormComponents/HttpCheckBearerToken.tsx +++ b/src/components/CheckEditor/FormComponents/HttpCheckBearerToken.tsx @@ -25,7 +25,7 @@ export const HttpCheckBearerToken = () => { > { control={control} render={({ field }) => { const { ref, ...rest } = field; - return { + field.onChange(value); + }} + /> + ); }} /> diff --git a/src/components/CheckEditor/FormComponents/RequestHeaders.tsx b/src/components/CheckEditor/FormComponents/RequestHeaders.tsx index 261790295..5dd558c45 100644 --- a/src/components/CheckEditor/FormComponents/RequestHeaders.tsx +++ b/src/components/CheckEditor/FormComponents/RequestHeaders.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { FieldPath } from 'react-hook-form'; +import { FieldPath, useFormContext } from 'react-hook-form'; import { OrgRole } from '@grafana/data'; import { Field } from '@grafana/ui'; +import { get } from 'lodash'; import { CheckFormValues } from 'types'; import { hasRole } from 'utils'; -import { validateHTTPHeaderName, validateHTTPHeaderValue } from 'validation'; import { NameValueInput, NameValueName } from 'components/NameValueInput/NameValueInput'; type RequestHeadersProps = { @@ -18,16 +18,25 @@ type RequestHeadersProps = { export const RequestHeaders = ({ ariaLabelSuffix, description, label, name, ...rest }: RequestHeadersProps) => { const isEditor = hasRole(OrgRole.Editor); + const { + formState: { errors }, + } = useFormContext(); + const fieldError = get(errors, name); + const errorMessage = fieldError?.message || fieldError?.root?.message; return ( - + diff --git a/src/components/CheckEditor/ProbeOptions.tsx b/src/components/CheckEditor/ProbeOptions.tsx index 1ef8f8c7d..fe3c97bb2 100644 --- a/src/components/CheckEditor/ProbeOptions.tsx +++ b/src/components/CheckEditor/ProbeOptions.tsx @@ -5,7 +5,7 @@ import { Field, Input } from '@grafana/ui'; import { CheckFormValues, CheckType, Probe } from 'types'; import { hasRole } from 'utils'; -import { validateFrequency, validateProbes, validateTimeout } from 'validation'; +import { validateTimeout } from 'validation'; import { useProbes } from 'data/useProbes'; import { SliderInput } from 'components/SliderInput'; import { Subheader } from 'components/Subheader'; @@ -80,7 +80,6 @@ export const ProbeOptions = ({ checkType }: Props) => { control={control} name="probes" - rules={{ validate: validateProbes }} render={({ field }) => ( { invalid={Boolean(errors.frequency)} error={errors.frequency?.message} > - validateFrequency(value, maxFrequency)} - name="frequency" - min={minFrequency} - max={maxFrequency} - /> + ({ defaultValues: initialValues, shouldFocusError: false, // we manage focus manually - mode: `onBlur`, + resolver: zodResolver(HttpCheckSchema), }); const { updateCheck, createCheck, deleteCheck, error, submitting } = useCUDChecks({ eventInfo: { checkType } }); @@ -77,7 +79,11 @@ const CheckFormContent = ({ check, checkType, overCheckLimit, overScriptedLimit const onSuccess = () => navigateBack(); const testRef = useRef(null); + console.log(formMethods.formState.errors); + console.log(formMethods.watch()); + const handleSubmit = (checkValues: CheckFormValues, event: BaseSyntheticEvent | undefined) => { + console.log(checkValues); // react-hook-form doesn't let us provide SubmitEvent to BaseSyntheticEvent const submitter = (event?.nativeEvent as SubmitEvent).submitter; const toSubmit = toPayload(checkValues); diff --git a/src/components/LabelField/LabelField.tsx b/src/components/LabelField/LabelField.tsx index 15c8f6b80..58d7799d9 100644 --- a/src/components/LabelField/LabelField.tsx +++ b/src/components/LabelField/LabelField.tsx @@ -3,11 +3,11 @@ import { useFormContext } from 'react-hook-form'; import { OrgRole } from '@grafana/data'; import { Alert, Button, Field, LoadingPlaceholder, Spinner, useTheme2 } from '@grafana/ui'; import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; import { Label } from 'types'; import { FaroEvent, reportEvent } from 'faro'; import { hasRole } from 'utils'; -import { validateLabelName, validateLabelValue } from 'validation'; import { ListTenantLimitsResponse } from 'datasource/responses.types'; import { useTenantLimits } from 'data/useTenantLimits'; import { NameValueInput } from 'components/NameValueInput'; @@ -42,14 +42,20 @@ function getDescription(labelDestination: LabelFieldProps['labelDestination'], l export const LabelField = ({ labelDestination }: LabelFieldProps) => { const { data: limits, isLoading, error, isRefetching, refetch } = useTenantLimits(); - const { watch } = useFormContext(); - const labels = watch('labels'); + const { formState } = useFormContext(); const isEditor = hasRole(OrgRole.Editor); const limit = getLimit(labelDestination, limits); const description = getDescription(labelDestination, limit, limits?.maxAllowedLogLabels ?? 5); + const labelError = formState.errors?.labels?.message || formState.errors?.labels?.root?.message; return ( - + {isLoading ? ( ) : ( @@ -60,8 +66,6 @@ export const LabelField = ({ labelDestination }: Label disabled={!isEditor} label="label" limit={limit} - validateName={(labelName) => validateLabelName(labelName, labels)} - validateValue={validateLabelValue} data-fs-element="Labels input" /> @@ -100,3 +104,7 @@ function LimitsFetchWarning({ ); } + +function parseErrorMessage(message: string | undefined, label: string) { + return message?.replace(`{type}`, capitalize(label)); +} diff --git a/src/components/NameValueInput/NameValueInput.tsx b/src/components/NameValueInput/NameValueInput.tsx index 712c48f1f..c8c975876 100644 --- a/src/components/NameValueInput/NameValueInput.tsx +++ b/src/components/NameValueInput/NameValueInput.tsx @@ -1,5 +1,5 @@ -import React, { useRef } from 'react'; -import { FieldErrorsImpl, useFieldArray, useFormContext } from 'react-hook-form'; +import React, { useCallback, useRef } from 'react'; +import { FieldErrorsImpl, useFieldArray, useFormContext, useFormState } from 'react-hook-form'; import { Button, Field, HorizontalGroup, Icon, IconButton, Input, useTheme2, VerticalGroup } from '@grafana/ui'; import { css } from '@emotion/css'; @@ -14,8 +14,6 @@ interface Props { limit?: number; disabled?: boolean; label: string; - validateName?: (name: string) => string | undefined; - validateValue?: (value: string) => string | undefined; 'data-fs-element'?: string; } @@ -34,88 +32,103 @@ function getErrors(errors: FieldErrorsImpl, name: NameV return undefined; } -export const NameValueInput = ({ - ariaLabelSuffix = ``, - name, - disabled, - limit, - label, - validateName, - validateValue, - ...rest -}: Props) => { +export const NameValueInput = ({ ariaLabelSuffix = ``, name, disabled, limit, label, ...rest }: Props) => { const { register, control, formState: { errors }, + trigger, } = useFormContext(); + const { isSubmitted } = useFormState({ control }); const addRef = useRef(null); const { fields, append, remove } = useFieldArray({ control, name }); const theme = useTheme2(); - const fieldError = getErrors(errors, name); + const handleTrigger = useCallback(() => { + if (isSubmitted) { + trigger(name); + } + }, [trigger, isSubmitted, name]); + return ( - {fields.map((field, index) => ( - - - - - - { + const labelNameField = register(`${name}.${index}.name`); + const labelValueField = register(`${name}.${index}.value`); + + return ( + + + { + labelNameField.onChange(v); + handleTrigger(); + }} + /> + + + { + labelValueField.onChange(v); + handleTrigger(); + }} + /> + + { + remove(index); + requestAnimationFrame(() => { + addRef.current?.focus(); + handleTrigger(); + }); + }} disabled={disabled} + tooltip="Delete" /> - - { - remove(index); - requestAnimationFrame(() => { - addRef.current?.focus(); - }); - }} - disabled={disabled} - tooltip="Delete" - /> - - ))} + + ); + })} {(limit === undefined || fields.length < limit) && (