diff --git a/package.json b/package.json index bdcdac4da..4398db0bd 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,6 @@ "@tanstack/react-query-devtools": "5.8.4", "constrained-editor-plugin": "^1.3.0", "eslint-plugin-simple-import-sort": "^10.0.0", - "flat": "6.0.1", "ip-address": "^7.1.0", "is-base64": "^1.1.0", "lodash": "4.17.21", diff --git a/src/components/AlertStatus/AlertStatus.tsx b/src/components/AlertStatus/AlertStatus.tsx index edb10792a..c2d641811 100644 --- a/src/components/AlertStatus/AlertStatus.tsx +++ b/src/components/AlertStatus/AlertStatus.tsx @@ -7,7 +7,7 @@ import { AlertSensitivity, Check, PrometheusAlertsGroup, ROUTES } from 'types'; import { InstanceContext } from 'contexts/InstanceContext'; import { useAlertRules } from 'hooks/useAlertRules'; import { AlertSensitivityBadge } from 'components/AlertSensitivityBadge'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { Toggletip } from 'components/Toggletip'; type AlertStatusProps = { diff --git a/src/components/CheckEditor/CheckEditor.types.ts b/src/components/CheckEditor/CheckEditor.types.ts index 8a453e0d7..bc67c3b44 100644 --- a/src/components/CheckEditor/CheckEditor.types.ts +++ b/src/components/CheckEditor/CheckEditor.types.ts @@ -17,30 +17,31 @@ export type FieldProps = { name: FieldPath; onChange?: (e: FormEvent) => void; 'aria-label'?: string; + section?: number; }; -export type TLSConfigFields = { - tlsServerName?: FieldProps; - tlsInsecureSkipVerify?: FieldProps; - tlsCaSCert?: FieldProps; - tlsClientCert?: FieldProps; - tlsClientKey?: FieldProps; +export type TLSConfigFields = { + tlsServerName?: FieldProps; + tlsInsecureSkipVerify?: FieldProps; + tlsCaSCert?: FieldProps; + tlsClientCert?: FieldProps; + tlsClientKey?: FieldProps; }; -export type HttpRequestFields = TLSConfigFields & { - method: FieldProps; - target: FieldProps; - requestHeaders: FieldProps; - requestBody: FieldProps; - followRedirects?: FieldProps; - ipVersion?: FieldProps; - requestContentType?: FieldProps; - requestContentEncoding?: FieldProps; - basicAuth?: FieldProps; - bearerToken?: FieldProps; - proxyUrl?: FieldProps; - proxyHeaders?: FieldProps; - queryParams?: FieldProps; +export type HttpRequestFields = TLSConfigFields & { + method: FieldProps; + target: FieldProps; + requestHeaders: FieldProps; + requestBody: FieldProps; + followRedirects?: FieldProps; + ipVersion?: FieldProps; + requestContentType?: FieldProps; + requestContentEncoding?: FieldProps; + basicAuth?: FieldProps; + bearerToken?: FieldProps; + proxyUrl?: FieldProps; + proxyHeaders?: FieldProps; + queryParams?: FieldProps; }; export type DNSRequestFields = { @@ -52,14 +53,14 @@ export type DNSRequestFields = { port: FieldProps; }; -export type GRPCRequestFields = TLSConfigFields & { +export type GRPCRequestFields = TLSConfigFields & { ipVersion: FieldProps; target: FieldProps; service: FieldProps; useTLS: FieldProps; }; -export type TCPRequestFields = TLSConfigFields & { +export type TCPRequestFields = TLSConfigFields & { target: FieldProps; ipVersion: FieldProps; useTLS: FieldProps; @@ -82,3 +83,13 @@ export type ScriptedRequestFields = { target: FieldProps; script: FieldProps; }; + +export type RequestFields = + | HttpRequestFields + | HttpRequestFields + | DNSRequestFields + | GRPCRequestFields + | TCPRequestFields + | PingRequestFields + | TracerouteRequestFields + | ScriptedRequestFields; diff --git a/src/components/CheckEditor/FormComponents/DNSRequest.tsx b/src/components/CheckEditor/FormComponents/DNSRequest.tsx index 7dfd65add..7692c3f19 100644 --- a/src/components/CheckEditor/FormComponents/DNSRequest.tsx +++ b/src/components/CheckEditor/FormComponents/DNSRequest.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { DNSRequestFields } from '../CheckEditor.types'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Request } from 'components/Request'; import { CheckIpVersion } from './CheckIpVersion'; @@ -15,32 +16,32 @@ interface DNSRequestProps { onTest: () => void; } -export const DNSRequest = ({ disabled, fields, onTest }: DNSRequestProps) => { - return ( - - - - - - - - ); -}; +export const DNSRequest = forwardRef( + ({ disabled, fields, onTest }, handleErrorRef) => { + return ( + + + + + + + + + + + + + + + + + + ); + } +); -const DNSRequestOptions = ({ disabled, fields }: Omit) => { - const ipVersionName = fields.ipVersion.name; - - return ( - - - - - - - - - - - - ); -}; +DNSRequest.displayName = 'DNSRequest'; diff --git a/src/components/CheckEditor/FormComponents/GRPCRequest.tsx b/src/components/CheckEditor/FormComponents/GRPCRequest.tsx index 0b63c4299..ebd1c962f 100644 --- a/src/components/CheckEditor/FormComponents/GRPCRequest.tsx +++ b/src/components/CheckEditor/FormComponents/GRPCRequest.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { GRPCRequestFields } from '../CheckEditor.types'; import { CheckType } from 'types'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Request } from 'components/Request'; import { TLSConfig } from 'components/TLSConfig'; @@ -15,33 +16,33 @@ interface GRPCRequestProps { onTest: () => void; } -export const GRPCRequest = ({ disabled, fields, onTest }: GRPCRequestProps) => { - return ( - - - - - - - - ); -}; +export const GRPCRequest = forwardRef( + ({ disabled, fields, onTest }, handleErrorRef) => { + return ( + + + + + + + + + + + + + + + + + + + ); + } +); -const GRPCRequestOptions = ({ disabled, fields }: Omit) => { - const ipVersionName = fields.ipVersion.name; - - return ( - - - - - - - - - - - - - ); -}; +GRPCRequest.displayName = 'GRPCRequest'; diff --git a/src/components/CheckEditor/FormComponents/HttpRequest.tsx b/src/components/CheckEditor/FormComponents/HttpRequest.tsx index 0f03714f6..6c4903ab1 100644 --- a/src/components/CheckEditor/FormComponents/HttpRequest.tsx +++ b/src/components/CheckEditor/FormComponents/HttpRequest.tsx @@ -1,12 +1,13 @@ -import React, { useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Select, Stack, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; import { HttpRequestFields, TLSConfigFields } from '../CheckEditor.types'; -import { HttpMethod } from 'types'; +import { CheckFormValuesHttp, CheckFormValuesMultiHttp, HttpMethod } from 'types'; import { getMethodColor, parseUrl } from 'utils'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { METHOD_OPTIONS } from 'components/constants'; import { Indent } from 'components/Indent'; import { QueryParams } from 'components/QueryParams'; @@ -23,13 +24,17 @@ import { RequestBodyTextArea } from './RequestBodyTextArea'; import { RequestHeaders } from './RequestHeaders'; import { RequestQueryParams } from './RequestQueryParams'; -interface HttpRequestProps { +interface HttpRequestProps { disabled?: boolean; - fields: HttpRequestFields; + fields: HttpRequestFields; onTest?: () => void; + onError?: () => void; } -export const HttpRequest = ({ disabled, fields, onTest }: HttpRequestProps) => { +export const HttpRequest = forwardRef< + HandleErrorRef, + HttpRequestProps | HttpRequestProps +>(({ disabled, fields, onTest }, handleErrorRef) => { const [showQueryParams, setShowQueryParams] = useState(false); const { control, setValue, watch } = useFormContext(); const id = `request-method-${fields.method.name}`; @@ -112,90 +117,102 @@ export const HttpRequest = ({ disabled, fields, onTest }: HttpRequestProps) => { )} )} - + ); -}; +}); + +HttpRequest.displayName = 'HttpRequest'; interface HttpRequestOptionsProps { disabled?: boolean; - fields: HttpRequestFields; + fields: HttpRequestFields | HttpRequestFields; } -const HttpRequestOptions = ({ disabled, fields }: HttpRequestOptionsProps) => { - const requestHeadersName = fields.requestHeaders.name; - const followRedirectsName = fields.followRedirects?.name; - const ipVersionName = fields.ipVersion?.name; - const requestBodyName = fields.requestBody?.name; - const requestContentTypeName = fields.requestContentType?.name; - const requestContentEncodingName = fields.requestContentEncoding?.name; - const authFields = fields.basicAuth || fields.bearerToken; - const tlsFields = getTLSFields(fields); - const proxyFields = getProxyFields(fields); +const HttpRequestOptions = forwardRef( + ({ disabled, fields }, handleErrorRef) => { + const requestHeadersName = fields.requestHeaders.name; + const followRedirectsName = fields.followRedirects?.name; + const ipVersionName = fields.ipVersion?.name; + const requestBodyName = fields.requestBody?.name; + const requestContentTypeName = fields.requestContentType?.name; + const requestContentEncodingName = fields.requestContentEncoding?.name; + const authFields = fields.basicAuth || fields.bearerToken; + const tlsFields = getTLSFields(fields); + const proxyFields = getProxyFields(fields); - return ( - - - - {followRedirectsName && } - {ipVersionName && ( - + + - )} - - {fields.queryParams && ( - - + {followRedirectsName && } + {ipVersionName && ( + + )} - )} - - {requestContentTypeName && } - {requestContentEncodingName && ( - + {fields.queryParams && ( + + + )} - - - {authFields && ( - - -
-

Authentication Type

- -
-
-
- )} - {tlsFields && ( - - - - )} - {proxyFields && proxyFields.headers && proxyFields.url && ( - - - + + {requestContentTypeName && } + {requestContentEncodingName && ( + + )} + - )} -
- ); -}; + {authFields && ( + + +
+

Authentication Type

+ +
+
+
+ )} + {tlsFields && ( + + + + )} + {proxyFields && proxyFields.headers && proxyFields.url && ( + + + + + )} + + ); + } +); + +HttpRequestOptions.displayName = 'HttpRequestOptions'; + +function getTLSFields( + fields: HttpRequestFields | HttpRequestFields +): TLSConfigFields | null { + if (isMultiHttpFields(fields)) { + return null; + } -function getTLSFields(fields: HttpRequestFields): TLSConfigFields | null { if ( !fields.tlsServerName && !fields.tlsInsecureSkipVerify && @@ -215,7 +232,14 @@ function getTLSFields(fields: HttpRequestFields): TLSConfigFields | null { }; } -function getProxyFields(fields: HttpRequestFields) { +// todo: this is a bit rubbish +function isMultiHttpFields( + fields: HttpRequestFields | HttpRequestFields +): fields is HttpRequestFields { + return fields.target.name.startsWith(`settings.multihttp`); +} + +function getProxyFields(fields: HttpRequestFields | HttpRequestFields) { if (!fields.proxyUrl && !fields.proxyHeaders) { return null; } diff --git a/src/components/CheckEditor/FormComponents/MultiHttpCheckRequests.tsx b/src/components/CheckEditor/FormComponents/MultiHttpCheckRequests.tsx index e8724301a..bbca6c5dc 100644 --- a/src/components/CheckEditor/FormComponents/MultiHttpCheckRequests.tsx +++ b/src/components/CheckEditor/FormComponents/MultiHttpCheckRequests.tsx @@ -1,28 +1,26 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { FieldErrors, useFieldArray, useFormContext } from 'react-hook-form'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, Stack, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { DataTestIds } from 'test/dataTestIds'; import { HttpRequestFields } from '../CheckEditor.types'; -import { CheckFormValues, CheckFormValuesMultiHttp, HttpMethod } from 'types'; +import { CheckFormInvalidSubmissionEvent, CheckFormValuesMultiHttp, HttpMethod } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; +import { broadcastFailedSubmission, flattenKeys } from 'components/CheckForm/checkForm.utils'; import { useCheckFormContext } from 'components/CheckForm/CheckFormContext/CheckFormContext'; import { ENTRY_INDEX_CHAR } from 'components/CheckForm/FormLayout/formlayout.utils'; import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; import { Indent } from 'components/Indent'; import { AvailableVariables } from 'components/MultiHttp/AvailableVariables'; import { MultiHttpCollapse } from 'components/MultiHttp/MultiHttpCollapse'; -import { - focusField, - getMultiHttpFormErrors, - useMultiHttpCollapseState, -} from 'components/MultiHttp/MultiHttpSettingsForm.utils'; +import { getMultiHttpFormErrors, useMultiHttpCollapseState } from 'components/MultiHttp/MultiHttpSettingsForm.utils'; import { HttpRequest } from './HttpRequest'; import { MultiHttpVariables } from './MultiHttpVariables'; -export const MULTI_HTTP_REQUEST_FIELDS: HttpRequestFields = { +export const MULTI_HTTP_REQUEST_FIELDS: HttpRequestFields = { target: { name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.url`, }, @@ -31,18 +29,23 @@ export const MULTI_HTTP_REQUEST_FIELDS: HttpRequestFields = { }, requestHeaders: { name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.headers`, + section: 0, + }, + queryParams: { + name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.queryFields`, + section: 1, }, requestBody: { name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.body.payload`, + section: 2, }, requestContentEncoding: { name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.body.contentEncoding`, + section: 2, }, requestContentType: { name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.body.contentType`, - }, - queryParams: { - name: `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request.queryFields`, + section: 2, }, }; @@ -70,28 +73,29 @@ export const MultiHttpCheckRequests = () => { const requests = watch('settings.multihttp.entries'); useEffect(() => { - const onErrorEvent = (e: CustomEvent>) => { - const errs = e.detail; - const res = getMultiHttpFormErrors(errs); - - if (res) { - dispatchCollapse({ - type: 'updateRequestPanel', - open: true, - index: res.index, - tab: res.tab, - }); - - if (panelRefs.current[res.index]) { - focusField(panelRefs.current[res.index], res.id); + const openRequest = (e: CustomEvent) => { + const { errs, source } = e.detail; + + // we need to check the source to avoid creating an infinite loop + // of rebroadcasting the same event + if (source !== `collapsible`) { + const res = getMultiHttpFormErrors(errs); + + if (res !== null) { + dispatchCollapse({ + type: 'openRequestPanels', + indexes: res, + }); + + broadcastFailedSubmission(errs, `collapsible`); } } }; - document.addEventListener(CHECK_FORM_ERROR_EVENT, onErrorEvent); + document.addEventListener(CHECK_FORM_ERROR_EVENT, openRequest); return () => { - document.removeEventListener(CHECK_FORM_ERROR_EVENT, onErrorEvent); + document.removeEventListener(CHECK_FORM_ERROR_EVENT, openRequest); }; }, [dispatchCollapse]); @@ -156,48 +160,76 @@ const MultiHttpRequest = ({ index }: { index: number }) => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; - const fields = Object.entries(MULTI_HTTP_REQUEST_FIELDS).reduce((acc, field) => { - const [key, value] = field; + const fields: HttpRequestFields = useMemo(() => { + const initial = Object.entries(MULTI_HTTP_REQUEST_FIELDS).reduce>( + (acc, field) => { + const [key, value] = field; + + return { + ...acc, + [key]: { + ...value, + name: value.name.replace(ENTRY_INDEX_CHAR, index.toString()), + }, + }; + }, + MULTI_HTTP_REQUEST_FIELDS + ); return { - ...acc, - [key]: { - ...value, - name: value.name.replace(ENTRY_INDEX_CHAR, index.toString()), + ...initial, + target: { + ...initial.target, + 'aria-label': `Request target for request ${index + 1} *`, + }, + method: { + ...initial.method, + 'aria-label': `Request method for request ${index + 1} *`, }, }; - }, MULTI_HTTP_REQUEST_FIELDS); + }, [index]); const onTest = useCallback(() => { addRequest(fields); }, [addRequest, fields]); - return ( - - ); + const { handleErrorRef } = useNestedRequestErrors(fields); + + return ; }; +MultiHttpRequest.displayName = 'MultiHttpRequest'; + const SetVariables = ({ index }: { index: number }) => { const [open, setOpen] = useState(false); + useEffect(() => { + const handleErrorEvent = (event: CustomEvent) => { + const { errs } = event.detail; + const errorKeys = flattenKeys(errs); + + if (errorKeys.some((key) => key.startsWith(`settings.multihttp.entries.${index}.variables`))) { + setOpen(true); + } + }; + + document.addEventListener(CHECK_FORM_ERROR_EVENT, handleErrorEvent); + + return () => { + document.removeEventListener(CHECK_FORM_ERROR_EVENT, handleErrorEvent); + }; + }, [index]); + return (
-
diff --git a/src/components/CheckEditor/FormComponents/PingRequest.tsx b/src/components/CheckEditor/FormComponents/PingRequest.tsx index e7f6b1b62..688b37c7b 100644 --- a/src/components/CheckEditor/FormComponents/PingRequest.tsx +++ b/src/components/CheckEditor/FormComponents/PingRequest.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { PingRequestFields } from '../CheckEditor.types'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Request } from 'components/Request'; import { CheckIpVersion } from './CheckIpVersion'; @@ -12,23 +13,27 @@ interface PingRequestProps { onTest: () => void; } -export const PingRequest = ({ disabled, fields, onTest }: PingRequestProps) => { - return ( - - - - - - - - - - - - - ); -}; +export const PingRequest = forwardRef( + ({ disabled, fields, onTest }, handleErrorRef) => { + return ( + + + + + + + + + + + + + ); + } +); + +PingRequest.displayName = 'PingRequest'; diff --git a/src/components/CheckEditor/FormComponents/ScriptedCheckScript.tsx b/src/components/CheckEditor/FormComponents/ScriptedCheckScript.tsx index 3e870d882..bdbb0d5fb 100644 --- a/src/components/CheckEditor/FormComponents/ScriptedCheckScript.tsx +++ b/src/components/CheckEditor/FormComponents/ScriptedCheckScript.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { FieldValidationMessage, Tab, TabContent, TabsBar } from '@grafana/ui'; @@ -6,6 +6,7 @@ import { CheckFormValuesScripted } from 'types'; import { useCheckFormContext } from 'components/CheckForm/CheckFormContext/CheckFormContext'; import { CodeEditor } from 'components/CodeEditor'; import { CodeSnippet } from 'components/CodeSnippet'; +import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; import { SCRIPT_EXAMPLES } from 'components/WelcomeTabs/constants'; enum ScriptEditorTabs { @@ -13,6 +14,8 @@ enum ScriptEditorTabs { Examples = 'examples', } +export const SCRIPT_TEXTAREA_ID = 'check-script-textarea'; + export const ScriptedCheckScript = () => { const { control, @@ -22,6 +25,20 @@ export const ScriptedCheckScript = () => { const [selectedTab, setSelectedTab] = useState(ScriptEditorTabs.Script); const fieldError = errors.settings?.scripted?.script; + useEffect(() => { + const goToScriptTab = () => { + if (fieldError) { + setSelectedTab(ScriptEditorTabs.Script); + } + }; + + document.addEventListener(CHECK_FORM_ERROR_EVENT, goToScriptTab); + + return () => { + document.removeEventListener(CHECK_FORM_ERROR_EVENT, goToScriptTab); + }; + }, [fieldError]); + return ( <> @@ -42,7 +59,7 @@ export const ScriptedCheckScript = () => { name="settings.scripted.script" control={control} render={({ field }) => { - return ; + return ; }} /> )} diff --git a/src/components/CheckEditor/FormComponents/TCPRequest.tsx b/src/components/CheckEditor/FormComponents/TCPRequest.tsx index 0b826b6d5..1cd69fbb5 100644 --- a/src/components/CheckEditor/FormComponents/TCPRequest.tsx +++ b/src/components/CheckEditor/FormComponents/TCPRequest.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { TCPRequestFields } from '../CheckEditor.types'; import { CheckType } from 'types'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Request } from 'components/Request'; import { TLSConfig } from 'components/TLSConfig'; @@ -14,30 +15,31 @@ interface TCPRequestProps { onTest: () => void; } -export const TCPRequest = ({ disabled, fields, onTest }: TCPRequestProps) => { - return ( - - - - - - - - ); -}; +export const TCPRequest = forwardRef( + ({ disabled, fields, onTest }, handleErrorRef) => { + return ( + + + + + -const TCPRequestOptions = ({ disabled, fields }: Omit) => { - const ipVersionName = fields.ipVersion.name; + + + + + + + + + + + ); + } +); - return ( - - - - - - - - - - ); -}; +TCPRequest.displayName = 'TCPRequest'; diff --git a/src/components/CheckEditor/FormComponents/TracerouteRequest.tsx b/src/components/CheckEditor/FormComponents/TracerouteRequest.tsx index 6ab2c947f..5763214cf 100644 --- a/src/components/CheckEditor/FormComponents/TracerouteRequest.tsx +++ b/src/components/CheckEditor/FormComponents/TracerouteRequest.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { TracerouteRequestFields } from '../CheckEditor.types'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Request } from 'components/Request'; import { TracerouteMaxHops } from './TracerouteMaxHops'; @@ -12,19 +13,23 @@ interface TracerouteRequestProps { fields: TracerouteRequestFields; } -export const TracerouteRequest = ({ disabled, fields }: TracerouteRequestProps) => { - return ( - - - - - - - - - - - - - ); -}; +export const TracerouteRequest = forwardRef( + ({ disabled, fields }, handleErrorRef) => { + return ( + + + + + + + + + + + + + ); + } +); + +TracerouteRequest.displayName = 'TracerouteRequest'; diff --git a/src/components/CheckForm/CheckFormContext/CheckFormContext.tsx b/src/components/CheckForm/CheckFormContext/CheckFormContext.tsx index d1febeb97..e945a1efd 100644 --- a/src/components/CheckForm/CheckFormContext/CheckFormContext.tsx +++ b/src/components/CheckForm/CheckFormContext/CheckFormContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, ReactNode, useContext, useMemo } from 'react'; -import { Request, RequestFields } from './CheckFormContext.types'; +import { Request } from './CheckFormContext.types'; +import { RequestFields } from 'components/CheckEditor/CheckEditor.types'; import { useTestRequests } from './useTestRequests'; diff --git a/src/components/CheckForm/CheckFormContext/CheckFormContext.types.ts b/src/components/CheckForm/CheckFormContext/CheckFormContext.types.ts index f2d14bb5c..15e7a8d49 100644 --- a/src/components/CheckForm/CheckFormContext/CheckFormContext.types.ts +++ b/src/components/CheckForm/CheckFormContext/CheckFormContext.types.ts @@ -1,12 +1,4 @@ import { CheckFormValues } from 'types'; -import { - DNSRequestFields, - GRPCRequestFields, - HttpRequestFields, - PingRequestFields, - TCPRequestFields, - TracerouteRequestFields, -} from 'components/CheckEditor/CheckEditor.types'; export type Request = { id: number; check: { @@ -19,11 +11,3 @@ export type Request = { result: any; }; }; - -export type RequestFields = - | HttpRequestFields - | DNSRequestFields - | GRPCRequestFields - | TCPRequestFields - | TracerouteRequestFields - | PingRequestFields; diff --git a/src/components/CheckForm/CheckFormContext/useTestRequests.ts b/src/components/CheckForm/CheckFormContext/useTestRequests.ts index 360cf8007..4e54c69a0 100644 --- a/src/components/CheckForm/CheckFormContext/useTestRequests.ts +++ b/src/components/CheckForm/CheckFormContext/useTestRequests.ts @@ -3,11 +3,12 @@ import { useFormContext } from 'react-hook-form'; import { DataFrameJSON, dateTime } from '@grafana/data'; import { merge } from 'lodash'; -import { Request, RequestFields } from './CheckFormContext.types'; +import { Request } from './CheckFormContext.types'; import { CheckFormValues, Probe } from 'types'; import { useTestCheck } from 'data/useChecks'; import { useLogs } from 'data/useLogs'; import { useProbes } from 'data/useProbes'; +import { RequestFields } from 'components/CheckEditor/CheckEditor.types'; import { toPayload } from 'components/CheckEditor/checkFormTransformations'; // todo: this needs work and isn't used currently diff --git a/src/components/CheckForm/FormLayout/FormLayout.test.tsx b/src/components/CheckForm/FormLayout/FormLayout.test.tsx index b7a8fb9db..7ed5ed9c1 100644 --- a/src/components/CheckForm/FormLayout/FormLayout.test.tsx +++ b/src/components/CheckForm/FormLayout/FormLayout.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { FieldValues, FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { screen, within } from '@testing-library/react'; import { z } from 'zod'; import { DataTestIds } from 'test/dataTestIds'; @@ -46,7 +47,7 @@ describe(`FormLayout`, () => { const { container, user } = render( - +
Second section content
@@ -117,7 +118,7 @@ describe(`FormLayout`, () => { render( - +
Second section content
@@ -133,7 +134,7 @@ describe(`FormLayout`, () => { const { container, user } = render( - +
Second section content
@@ -156,7 +157,7 @@ describe(`FormLayout`, () => { const { container, user } = render( - +
Second section content
@@ -184,7 +185,7 @@ describe(`FormLayout`, () => {
{`NOT A FORM SECTION. DON'T COUNT ME`}
- +
Second section content
@@ -238,7 +239,7 @@ describe(`FormLayout`, () => { const { user } = render( - +
Second section content
@@ -254,12 +255,48 @@ describe(`FormLayout`, () => { expect(within(actionsBar).queryByText(sectionOneCustomButtonText)).not.toBeInTheDocument(); expect(within(actionsBar).getByText(sectionTwoCustomButtonText)).toBeInTheDocument(); }); + + it(`moves to the first step with an error on submission`, async () => { + const secondSectionContent = `Second section content`; + const thirdSectionContent = `Third section content`; + const thirdSectionLabel = `Third section`; + + const { user } = render( + + + + + + +
{secondSectionContent}
+
+ +
{thirdSectionContent}
+
+
+ ); + + const jobInput = await screen.findByLabelText(`Job`); + await user.type(jobInput, `job`); + const lastSection = await screen.findByText(thirdSectionLabel); + await user.click(lastSection); + expect(screen.getByText(thirdSectionContent)).toBeInTheDocument(); + await user.click(screen.getByText(`Submit`)); + const firstSection = await screen.findByText(secondSectionContent); + expect(firstSection).toBeInTheDocument(); + }); }); type TestValues = { job: string; + target: string; }; +const schema = z.object({ + job: z.string().min(1), + target: z.string().min(1), +}); + const TestForm = ({ actions, children, @@ -268,7 +305,9 @@ const TestForm = ({ const formMethods = useForm({ defaultValues: { job: ``, + target: ``, }, + resolver: zodResolver(schema), }); return ( @@ -278,9 +317,7 @@ const TestForm = ({ disabled={disabled} onSubmit={formMethods.handleSubmit} onValid={(v) => v} - schema={z.object({ - job: z.string().min(1), - })} + schema={schema} > {children} @@ -288,8 +325,20 @@ const TestForm = ({ ); }; -const NameInput = () => { +const JobInput = () => { + const { register } = useFormContext(); + const id = `job`; + + return ( + <> + + + + ); +}; + +const TargetInput = () => { const { register } = useFormContext(); - return ; + return ; }; diff --git a/src/components/CheckForm/FormLayout/FormLayout.tsx b/src/components/CheckForm/FormLayout/FormLayout.tsx index 5457681fb..291c44e7f 100644 --- a/src/components/CheckForm/FormLayout/FormLayout.tsx +++ b/src/components/CheckForm/FormLayout/FormLayout.tsx @@ -3,11 +3,11 @@ import { FieldErrors, FieldValues, SubmitHandler } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, Stack, useStyles2 } from '@grafana/ui'; import { css, cx } from '@emotion/css'; -import { flatten } from 'flat'; import { ZodType } from 'zod'; import { DataTestIds } from 'test/dataTestIds'; -import { findFieldToFocus, useFormLayout } from './formlayout.utils'; +import { flattenKeys } from '../checkForm.utils'; +import { normalizeFlattenedErrors, useFormLayout } from './formlayout.utils'; import { FormSection, FormSectionInternal } from './FormSection'; import { FormSidebar } from './FormSidebar'; @@ -76,38 +76,32 @@ export const FormLayout = ({ const handleValid = useCallback( (formValues: T, event: BaseSyntheticEvent | undefined) => { - handleVisited(sections.map((section) => section.props.index)); + handleVisited(formSections.map((section) => section.props.index)); onValid(formValues, event); }, - [handleVisited, onValid, sections] + [handleVisited, onValid, formSections] ); - const handleError = useCallback( + const handleInvalid = useCallback( (errs: FieldErrors) => { - handleVisited(sections.map((section) => section.props.index)); - const flattenedErrors = Object.keys(flatten(errs)); - // Find the first section that has a field with an error. - const errSection = sections?.find((section) => - flattenedErrors.find((errName: string) => { - return section.props.fields?.some((field: string) => errName.startsWith(field)); - }) - ); + handleVisited(formSections.map((section) => section.props.index)); + const flattenedErrors = normalizeFlattenedErrors(flattenKeys(errs)); - if (errSection !== undefined) { - setActiveSection(errSection.props.index); - } + const errSection = formSections?.find((section) => { + const fields = section.props.fields; - const shouldFocus = findFieldToFocus(errs); + return flattenedErrors.find((errName: string) => { + return fields?.some((field: string) => errName.startsWith(field)); + }); + }); - // can't pass refs to all fields so have to manage it automatically - if (shouldFocus) { - shouldFocus.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); - shouldFocus.focus?.({ preventScroll: true }); + if (errSection !== undefined) { + setActiveSection(errSection.props.index); } onInvalid?.(errs); }, - [handleVisited, onInvalid, sections, setActiveSection] + [handleVisited, onInvalid, formSections, setActiveSection] ); const actionButtons = actions?.find((action) => action.index === activeSection)?.element; @@ -122,7 +116,7 @@ export const FormLayout = ({ visitedSections={visitedSections} schema={schema} /> -
+
{sections}
diff --git a/src/components/CheckForm/FormLayout/formlayout.utils.ts b/src/components/CheckForm/FormLayout/formlayout.utils.ts index efd9e1561..63add74c6 100644 --- a/src/components/CheckForm/FormLayout/formlayout.utils.ts +++ b/src/components/CheckForm/FormLayout/formlayout.utils.ts @@ -1,10 +1,8 @@ import { useCallback, useState } from 'react'; -import { FieldErrors, FieldPath, FieldValues } from 'react-hook-form'; +import { FieldPath, FieldValues } from 'react-hook-form'; import { uniq } from 'lodash'; import { ZodType } from 'zod'; -import { PROBES_SELECT_ID } from 'components/CheckEditor/CheckProbes'; - // because we have separated multihttp assertions we need a way to say that no matter the // entry's index this error belongs to the steps section or the uptime definition step // so we have to wildcard the entry index in form errors @@ -74,46 +72,12 @@ export function checkForErrors({ }; } -export function findFieldToFocus(errs: FieldErrors): HTMLElement | undefined { - if (shouldFocusProbes(errs)) { - return document.querySelector(`#${PROBES_SELECT_ID} input`) || undefined; - } - - const ref = findRef(errs); - const isVisible = ref?.offsetParent !== null; - return isVisible ? ref : undefined; -} - -function findRef(target: any): HTMLElement | undefined { - if (Array.isArray(target)) { - let ref; - for (let i = 0; i < target.length; i++) { - const found = findRef(target[i]); - - if (found) { - ref = found; - break; - } +export function normalizeFlattenedErrors(errors: string[]) { + return errors.map((error) => { + if (error.startsWith(`settings.multihttp.entries`)) { + return error.replace(/\.[0-9]\./, `.${ENTRY_INDEX_CHAR}.`); } - return ref; - } - - if (target !== null && typeof target === `object`) { - if (target.ref) { - return target.ref; - } - - return findRef(Object.values(target)); - } - - return undefined; -} - -function shouldFocusProbes(errs: FieldErrors) { - if (errs?.job || errs?.target) { - return false; - } - - return `probes` in errs; + return error; + }); } diff --git a/src/components/CheckForm/FormLayouts/CheckDNSLayout.tsx b/src/components/CheckForm/FormLayouts/CheckDNSLayout.tsx index 767c09722..c422addbf 100644 --- a/src/components/CheckForm/FormLayouts/CheckDNSLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckDNSLayout.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { LayoutSection, Section } from './Layout.types'; import { CheckFormValuesDns } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { DNSRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { DNSCheckResponseMatches } from 'components/CheckEditor/FormComponents/DNSCheckResponseMatches'; @@ -17,35 +18,41 @@ export const DNS_REQUEST_FIELDS: DNSRequestFields = { }, ipVersion: { name: `settings.dns.ipVersion`, + section: 0, }, recordType: { name: `settings.dns.recordType`, + section: 1, }, server: { name: `settings.dns.server`, + section: 1, }, protocol: { name: `settings.dns.protocol`, + section: 1, }, port: { name: `settings.dns.port`, + section: 1, }, }; const CheckDNSRequest = () => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; + const { handleErrorRef } = useNestedRequestErrors(DNS_REQUEST_FIELDS); const onTest = useCallback(() => { addRequest(DNS_REQUEST_FIELDS); }, [addRequest]); - return ; + return ; }; export const DNSCheckLayout: Partial>> = { [LayoutSection.Check]: { - fields: [`target`], + fields: Object.values(DNS_REQUEST_FIELDS).map((field) => field.name), Component: ( <> diff --git a/src/components/CheckForm/FormLayouts/CheckGrpcLayout.tsx b/src/components/CheckForm/FormLayouts/CheckGrpcLayout.tsx index a324f4e16..024754ba5 100644 --- a/src/components/CheckForm/FormLayouts/CheckGrpcLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckGrpcLayout.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { LayoutSection, Section } from './Layout.types'; import { CheckFormValuesGRPC } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { GRPCRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { GRPCRequest } from 'components/CheckEditor/FormComponents/GRPCRequest'; @@ -15,44 +16,53 @@ export const GRPC_REQUEST_FIELDS: GRPCRequestFields = { }, ipVersion: { name: `settings.grpc.ipVersion`, + section: 0, }, service: { name: `settings.grpc.service`, + section: 1, }, useTLS: { name: `settings.grpc.tls`, + section: 2, }, tlsServerName: { name: `settings.grpc.tlsConfig.serverName`, + section: 2, }, tlsInsecureSkipVerify: { name: `settings.grpc.tlsConfig.insecureSkipVerify`, + section: 2, }, tlsCaSCert: { name: `settings.grpc.tlsConfig.caCert`, + section: 2, }, tlsClientCert: { name: `settings.grpc.tlsConfig.clientCert`, + section: 2, }, tlsClientKey: { name: `settings.grpc.tlsConfig.clientKey`, + section: 2, }, }; const CheckGRPCRequest = () => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; + const { handleErrorRef } = useNestedRequestErrors(GRPC_REQUEST_FIELDS); const onTest = useCallback(() => { addRequest(GRPC_REQUEST_FIELDS); }, [addRequest]); - return ; + return ; }; export const GRPCCheckLayout: Partial>> = { [LayoutSection.Check]: { - fields: [`target`], + fields: Object.values(GRPC_REQUEST_FIELDS).map((field) => field.name), Component: ( <> diff --git a/src/components/CheckForm/FormLayouts/CheckHttpLayout.tsx b/src/components/CheckForm/FormLayouts/CheckHttpLayout.tsx index ede3274d2..aa4e09bb9 100644 --- a/src/components/CheckForm/FormLayouts/CheckHttpLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckHttpLayout.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react'; import { LayoutSection, Section } from './Layout.types'; -import { CheckFormValues } from 'types'; +import { CheckFormValues, CheckFormValuesHttp } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { HttpRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { HttpCheckCompressionOption } from 'components/CheckEditor/FormComponents/HttpCheckCompressionOption'; @@ -14,7 +15,7 @@ import { Timeout } from 'components/CheckEditor/FormComponents/Timeout'; import { useCheckFormContext } from '../CheckFormContext/CheckFormContext'; -export const HTTP_REQUEST_FIELDS: HttpRequestFields = { +export const HTTP_REQUEST_FIELDS: HttpRequestFields = { target: { name: `target`, }, @@ -23,51 +24,64 @@ export const HTTP_REQUEST_FIELDS: HttpRequestFields = { }, requestHeaders: { name: `settings.http.headers`, + section: 0, }, ipVersion: { name: `settings.http.ipVersion`, + section: 0, }, requestBody: { name: `settings.http.body`, + section: 2, }, basicAuth: { name: `settings.http.basicAuth`, + section: 3, }, bearerToken: { name: `settings.http.bearerToken`, + section: 3, }, tlsServerName: { name: `settings.http.tlsConfig.serverName`, + section: 4, }, tlsInsecureSkipVerify: { name: `settings.http.tlsConfig.insecureSkipVerify`, + section: 4, }, tlsCaSCert: { name: `settings.http.tlsConfig.caCert`, + section: 4, }, tlsClientCert: { name: `settings.http.tlsConfig.clientCert`, + section: 4, }, tlsClientKey: { name: `settings.http.tlsConfig.clientKey`, + section: 4, }, proxyUrl: { name: `settings.http.proxyURL`, + section: 5, }, proxyHeaders: { name: `settings.http.proxyConnectHeaders`, + section: 5, }, }; const CheckHttpRequest = () => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; + const { handleErrorRef } = useNestedRequestErrors(HTTP_REQUEST_FIELDS); const onTest = useCallback(() => { addRequest(HTTP_REQUEST_FIELDS); }, [addRequest]); - return ; + return ; }; export const HttpCheckLayout: Partial>> = { diff --git a/src/components/CheckForm/FormLayouts/CheckMultiHttpLayout.tsx b/src/components/CheckForm/FormLayouts/CheckMultiHttpLayout.tsx index e4111badc..341729254 100644 --- a/src/components/CheckForm/FormLayouts/CheckMultiHttpLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckMultiHttpLayout.tsx @@ -10,7 +10,10 @@ import { ENTRY_INDEX_CHAR } from '../FormLayout/formlayout.utils'; export const MultiHTTPCheckLayout: Partial>> = { [LayoutSection.Check]: { - fields: [`settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request`], + fields: [ + `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.request`, + `settings.multihttp.entries.${ENTRY_INDEX_CHAR}.variables`, + ], Component: ( <> diff --git a/src/components/CheckForm/FormLayouts/CheckPingLayout.tsx b/src/components/CheckForm/FormLayouts/CheckPingLayout.tsx index 32499f39f..45b082e0d 100644 --- a/src/components/CheckForm/FormLayouts/CheckPingLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckPingLayout.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { LayoutSection, Section } from './Layout.types'; import { CheckFormValuesPing } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { PingRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { PingRequest } from 'components/CheckEditor/FormComponents/PingRequest'; @@ -15,21 +16,24 @@ const PING_FIELDS: PingRequestFields = { }, ipVersion: { name: `settings.ping.ipVersion`, + section: 0, }, dontFragment: { name: `settings.ping.dontFragment`, + section: 0, }, }; const CheckPingRequest = () => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; + const { handleErrorRef } = useNestedRequestErrors(PING_FIELDS); const onTest = useCallback(() => { addRequest(PING_FIELDS); }, [addRequest]); - return ; + return ; }; export const PingCheckLayout: Partial>> = { diff --git a/src/components/CheckForm/FormLayouts/CheckTCPLayout.tsx b/src/components/CheckForm/FormLayouts/CheckTCPLayout.tsx index 0782ff1af..4fb5c5472 100644 --- a/src/components/CheckForm/FormLayouts/CheckTCPLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckTCPLayout.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { LayoutSection, Section } from './Layout.types'; import { CheckFormValuesTcp } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { TCPRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { TCPCheckQueryAndResponse } from 'components/CheckEditor/FormComponents/TCPCheckQueryAndResponse'; @@ -10,47 +11,55 @@ import { Timeout } from 'components/CheckEditor/FormComponents/Timeout'; import { useCheckFormContext } from '../CheckFormContext/CheckFormContext'; -const TCP_FIELDS: TCPRequestFields = { +const TCP_REQUEST_FIELDS: TCPRequestFields = { target: { name: `target`, }, ipVersion: { name: `settings.tcp.ipVersion`, + section: 0, }, useTLS: { name: `settings.tcp.tls`, + section: 1, }, tlsServerName: { name: `settings.tcp.tlsConfig.serverName`, + section: 1, }, tlsInsecureSkipVerify: { name: `settings.tcp.tlsConfig.insecureSkipVerify`, + section: 1, }, tlsCaSCert: { name: `settings.tcp.tlsConfig.caCert`, + section: 1, }, tlsClientCert: { name: `settings.tcp.tlsConfig.clientCert`, + section: 1, }, tlsClientKey: { name: `settings.tcp.tlsConfig.clientKey`, + section: 1, }, }; const CheckTCPRequest = () => { const { isFormDisabled, supportingContent } = useCheckFormContext(); const { addRequest } = supportingContent; + const { handleErrorRef } = useNestedRequestErrors(TCP_REQUEST_FIELDS); const onTest = useCallback(() => { - addRequest(TCP_FIELDS); + addRequest(TCP_REQUEST_FIELDS); }, [addRequest]); - return ; + return ; }; export const TCPCheckLayout: Partial>> = { [LayoutSection.Check]: { - fields: [`target`], + fields: Object.values(TCP_REQUEST_FIELDS).map((field) => field.name), Component: ( <> diff --git a/src/components/CheckForm/FormLayouts/CheckTracerouteLayout.tsx b/src/components/CheckForm/FormLayouts/CheckTracerouteLayout.tsx index 25609d01e..906669509 100644 --- a/src/components/CheckForm/FormLayouts/CheckTracerouteLayout.tsx +++ b/src/components/CheckForm/FormLayouts/CheckTracerouteLayout.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { LayoutSection, Section } from './Layout.types'; import { CheckFormValuesTraceroute } from 'types'; +import { useNestedRequestErrors } from 'hooks/useNestedRequestErrors'; import { TracerouteRequestFields } from 'components/CheckEditor/CheckEditor.types'; import { CheckPublishedAdvanceMetrics } from 'components/CheckEditor/FormComponents/CheckPublishedAdvanceMetrics'; import { Timeout } from 'components/CheckEditor/FormComponents/Timeout'; @@ -15,28 +16,27 @@ const TRACEROUTE_FIELDS: TracerouteRequestFields = { }, maxHops: { name: `settings.traceroute.maxHops`, + section: 0, }, maxUnknownHops: { name: `settings.traceroute.maxUnknownHops`, + section: 0, }, ptrLookup: { name: `settings.traceroute.ptrLookup`, + section: 0, }, }; const CheckTracerouteRequest = () => { const { isFormDisabled } = useCheckFormContext(); + const { handleErrorRef } = useNestedRequestErrors(TRACEROUTE_FIELDS); - return ; + return ; }; export const TracerouteCheckLayout: Partial>> = { [LayoutSection.Check]: { - fields: [ - `target`, - `settings.traceroute.maxHops`, - `settings.traceroute.maxUnknownHops`, - `settings.traceroute.ptrLookup`, - ], + fields: Object.values(TRACEROUTE_FIELDS).map((field) => field.name), Component: (
diff --git a/src/components/CheckForm/checkForm.hooks.ts b/src/components/CheckForm/checkForm.hooks.ts index 975bd350a..60b7fbe8a 100644 --- a/src/components/CheckForm/checkForm.hooks.ts +++ b/src/components/CheckForm/checkForm.hooks.ts @@ -14,8 +14,8 @@ import { AdHocCheckResponse } from 'datasource/responses.types'; import { useCUDChecks, useTestCheck } from 'data/useChecks'; import { useNavigation } from 'hooks/useNavigation'; import { toPayload } from 'components/CheckEditor/checkFormTransformations'; -import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; +import { broadcastFailedSubmission, findFieldToFocus } from './checkForm.utils'; import { useFormCheckType } from './useCheckType'; const schemaMap = { @@ -83,7 +83,12 @@ export function useCheckForm({ check, checkType, onTestSuccess }: UseCheckFormPr ); const handleInvalid = useCallback((errs: FieldErrors) => { - document.dispatchEvent(new CustomEvent(CHECK_FORM_ERROR_EVENT, { detail: errs })); + broadcastFailedSubmission(errs, `submission`); + + // wait for the fields to be rendered after discovery + setTimeout(() => { + findFieldToFocus(errs); + }, 100); }, []); return { diff --git a/src/components/CheckForm/checkForm.utils.ts b/src/components/CheckForm/checkForm.utils.ts new file mode 100644 index 000000000..c055846c3 --- /dev/null +++ b/src/components/CheckForm/checkForm.utils.ts @@ -0,0 +1,73 @@ +import { FieldErrors } from 'react-hook-form'; + +import { CheckFormValues } from 'types'; +import { PROBES_SELECT_ID } from 'components/CheckEditor/CheckProbes'; +import { SCRIPT_TEXTAREA_ID } from 'components/CheckEditor/FormComponents/ScriptedCheckScript'; +import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; + +export function flattenKeys(errs: FieldErrors) { + const build: string[] = []; + + Object.entries(errs).forEach(([key, value]) => { + if (isBottomOfPath(value)) { + build.push(key); + } else { + build.push(...flattenKeys(value).map((subKey) => `${key}.${subKey}`)); + } + }); + + return build; +} + +function isBottomOfPath(obj: any) { + const keys = Object.keys(obj); + + if (keys.every((key) => [`ref`, `message`, `type`].includes(key))) { + return true; + } + + return false; +} + +export function broadcastFailedSubmission(errs: FieldErrors, source?: `submission` | `collapsible`) { + requestAnimationFrame(() => { + document.dispatchEvent(new CustomEvent(CHECK_FORM_ERROR_EVENT, { detail: { errs, source } })); + }); +} + +export function findFieldToFocus(errs: FieldErrors) { + const fieldToFocus = getFirstInput(errs); + + if (fieldToFocus instanceof HTMLElement) { + fieldToFocus.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); + fieldToFocus.focus(); + } +} + +function getFirstInput(errs: FieldErrors) { + const errKeys = flattenKeys(errs); + const onPageInputs = document.querySelectorAll(errKeys.map((key) => `[name="${key}"]`).join(',')); + const firstInput = onPageInputs[0]; + + if (firstInput) { + return firstInput; + } + + return searchForSpecialInputs(errKeys); +} + +function searchForSpecialInputs(errKeys: string[] = []) { + const probes = errKeys.includes(`probes`) && document.querySelector(`#${PROBES_SELECT_ID} input`); + const script = + errKeys.includes(`settings.scripted.script`) && document.querySelector(`#${SCRIPT_TEXTAREA_ID} textarea`); + + if (probes) { + return probes; + } + + if (script) { + return script; + } + + return null; +} diff --git a/src/components/CheckList/CheckList.test.tsx b/src/components/CheckList/CheckList.test.tsx index a1555ecb1..d224fda6c 100644 --- a/src/components/CheckList/CheckList.test.tsx +++ b/src/components/CheckList/CheckList.test.tsx @@ -15,7 +15,7 @@ import { server } from 'test/server'; import { getSelect, selectOption } from 'test/utils'; import { Check, FeatureName, ROUTES } from 'types'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; import { CheckList } from './CheckList'; diff --git a/src/components/CheckListItem/CheckItemActionButtons.tsx b/src/components/CheckListItem/CheckItemActionButtons.tsx index 74ead6d1c..c83d5dd03 100644 --- a/src/components/CheckListItem/CheckItemActionButtons.tsx +++ b/src/components/CheckListItem/CheckItemActionButtons.tsx @@ -7,8 +7,8 @@ import { Check, ROUTES } from 'types'; import { getCheckType, getCheckTypeGroup, hasRole } from 'utils'; import { useDeleteCheck } from 'data/useChecks'; import { useNavigation } from 'hooks/useNavigation'; -import { PLUGIN_URL_PATH } from 'components/constants'; -import { getRoute } from 'components/Routing'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; +import { getRoute } from 'components/Routing.utils'; const getStyles = (theme: GrafanaTheme2) => ({ actionButtonGroup: css` diff --git a/src/components/ChooseCheckGroup.tsx b/src/components/ChooseCheckGroup.tsx index 2c183f9c7..67d3e597f 100644 --- a/src/components/ChooseCheckGroup.tsx +++ b/src/components/ChooseCheckGroup.tsx @@ -8,7 +8,7 @@ import { CheckTypeGroup, ROUTES } from 'types'; import { CheckTypeGroupOption, ProtocolOption, useCheckTypeGroupOptions } from 'hooks/useCheckTypeGroupOptions'; import { useLimits } from 'hooks/useLimits'; import { PluginPage } from 'components/PluginPage'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { Toggletip } from 'components/Toggletip'; import { Card } from './Card'; diff --git a/src/components/CodeEditor/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 0310b99cc..6099bcfd4 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -45,15 +45,16 @@ export const CodeEditor = forwardRef(function CodeEditor( { checkJs = true, constrainedRanges, + id, language = 'javascript', - overlayMessage, - readOnly, - renderHeader, - value, onBeforeEditorMount, onChange, onDidChangeContentInEditableRange, onValidation, + overlayMessage, + readOnly, + renderHeader, + value, }: CodeEditorProps & ConstrainedEditorProps, ref ) { @@ -145,7 +146,7 @@ export const CodeEditor = forwardRef(function CodeEditor( }, [value, constrainedRanges]); return ( -
+
{renderHeader && renderHeader({ scriptValue: value })} {/* {overlayMessage && {overlayMessage}} */} ReactNode; - overlayMessage?: ReactNode; - value: string; onBeforeEditorMount?: (monaco: typeof monacoType) => void; onChange?: (value: string) => void; onValidation?: (hasError: boolean, value: string) => void; + overlayMessage?: ReactNode; + readOnly?: boolean; + renderHeader?: ({ scriptValue }: { scriptValue: string }) => ReactNode; + value: string; } export interface ConstrainedEditorProps { diff --git a/src/components/CodeSnippet/CodeSnippet.tsx b/src/components/CodeSnippet/CodeSnippet.tsx index 798216f9b..c539375c4 100644 --- a/src/components/CodeSnippet/CodeSnippet.tsx +++ b/src/components/CodeSnippet/CodeSnippet.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ClipboardButton, Tab, TabsBar, useStyles2 } from '@grafana/ui'; import { cx } from '@emotion/css'; -import Prism, { Grammar } from 'prismjs'; +import { highlight, languages } from 'prismjs'; import { CodeSnippetGroupProps, CodeSnippetProps, CodeSnippetTabProps } from './CodeSnippet.types'; @@ -88,11 +88,11 @@ export const CodeSnippet = ({ }, [activeTab, initialTab]); const formatter = snippetTab?.dedent ? formatCode : identity; - const snippet = snippetTab?.code ?? code; + const snippet = snippetTab.code.toString(); const langSyntax = snippetTab?.lang || lang; const derivedActiveTab = activeTab ?? tabs[0]?.value; const highlightedSyntax = useMemo( - () => snippet && Prism.highlight(formatter(snippet), Prism.languages[langSyntax] as Grammar, langSyntax), + () => snippet && highlight(formatter(snippet), languages[langSyntax], langSyntax), [formatter, snippet, langSyntax] ); diff --git a/src/components/MultiHttp/MultiHttpCollapse.tsx b/src/components/MultiHttp/MultiHttpCollapse.tsx index 325e9adc9..634b99fdd 100644 --- a/src/components/MultiHttp/MultiHttpCollapse.tsx +++ b/src/components/MultiHttp/MultiHttpCollapse.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, PropsWithChildren, ReactNode } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, Icon, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import { HttpMethod } from 'types'; import { getMethodColor } from 'utils'; @@ -37,6 +37,7 @@ export const MultiHttpCollapse = forwardRef
{requestMethod}
@@ -46,25 +47,29 @@ export const MultiHttpCollapse = forwardRef} - {isOpen && ( -
-
- {onRemove && ( -
-
{children}
+ +
+
+ {onRemove && ( +
- )} +
{children}
+
); } @@ -92,10 +97,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginLeft: theme.spacing(1), }), body: css({ - display: `grid`, + display: `none`, gridTemplateColumns: `36px auto`, gap: theme.spacing(2.5), }), + isOpen: css({ + display: `grid`, + }), actions: css({ borderRight: `1px solid ${theme.colors.border.medium}`, }), diff --git a/src/components/MultiHttp/MultiHttpSettingsForm.utils.ts b/src/components/MultiHttp/MultiHttpSettingsForm.utils.ts index 57c437b83..19fd85f5c 100644 --- a/src/components/MultiHttp/MultiHttpSettingsForm.utils.ts +++ b/src/components/MultiHttp/MultiHttpSettingsForm.utils.ts @@ -3,44 +3,39 @@ import { FieldErrors } from 'react-hook-form'; import { CheckFormValuesMultiHttp } from 'types'; -import { MultiHttpFormTabs } from './MultiHttpTypes'; - -const tabOrder = [ - MultiHttpFormTabs.Headers, - MultiHttpFormTabs.QueryParams, - MultiHttpFormTabs.Assertions, - MultiHttpFormTabs.Variables, - MultiHttpFormTabs.Body, -]; - type FormErrors = FieldErrors; -export const tabErrorMap = (errors: FormErrors, index: number, tab: MultiHttpFormTabs) => { - const entry = errors?.settings?.multihttp?.entries?.[index]; - - const tabErrorPathMap = { - [MultiHttpFormTabs.Headers]: entry?.request?.headers?.length, - [MultiHttpFormTabs.QueryParams]: entry?.request?.queryFields?.length, - [MultiHttpFormTabs.Assertions]: entry?.checks?.length, - [MultiHttpFormTabs.Variables]: entry?.variables?.length, - [MultiHttpFormTabs.Body]: false, // Body tab does not have any validation - }; +type RequestPanelState = { + open: boolean; +}; - return tabErrorPathMap[tab]; +type AddAction = { + type: `addNewRequest`; }; -type RequestPanelState = { +type UpdateAction = { + type: `updateRequestPanel`; + index: number; open: boolean; - activeTab: MultiHttpFormTabs; }; -type Action = { - type: string; - index?: number; - open?: boolean; - tab?: MultiHttpFormTabs; +type OpenMultipleAction = { + type: `openRequestPanels`; + indexes: number[]; +}; + +type RemoveAction = { + type: `removeRequest`; + index: number; +}; + +type ToggleAction = { + type: `toggle`; + index: number; }; +type Action = UpdateAction | AddAction | OpenMultipleAction | RemoveAction | ToggleAction; + function reducer(state: RequestPanelState[], action: Action) { switch (action.type) { case 'addNewRequest': @@ -54,7 +49,6 @@ function reducer(state: RequestPanelState[], action: Action) { ...newState, { open: true, - activeTab: MultiHttpFormTabs.Headers, }, ]; case 'removeRequest': @@ -76,12 +70,23 @@ function reducer(state: RequestPanelState[], action: Action) { return { ...value, open: action.open || value.open, - activeTab: action.tab || value.activeTab, }; } return value; }); } + case 'openRequestPanels': { + return state.map((value, index) => { + if (action.indexes.includes(index)) { + return { + ...value, + open: true, + }; + } + + return value; + }); + } default: return state; } @@ -90,88 +95,17 @@ function reducer(state: RequestPanelState[], action: Action) { export function useMultiHttpCollapseState(check: CheckFormValuesMultiHttp) { const initialState = check.settings.multihttp.entries?.map((_, index, arr) => ({ open: index === arr.length - 1, - activeTab: MultiHttpFormTabs.Headers, - })) ?? [{ open: true, activeTab: MultiHttpFormTabs.Headers }]; + })) ?? [{ open: true }]; return useReducer(reducer, initialState); } export function getMultiHttpFormErrors(errs: FormErrors) { - const errKeys = Object.keys(errs); - - if (errKeys.some((key) => key !== 'settings')) { - return false; - } - const entries = errs.settings?.multihttp?.entries; - const isMultiHttpError = errKeys.length === 1 && entries; - const firstCollapsibleError = entries?.findIndex?.(Boolean); - - if (isMultiHttpError && typeof firstCollapsibleError === 'number') { - const firstTabWithErrors = tabOrder - .map((tab) => { - if (tabErrorMap(errs, firstCollapsibleError, tab)) { - return tab; - } - - return false; - }) - .filter(Boolean); - - return { - id: `settings.multihttp.entries${findPath(entries, 'message')}`, - index: firstCollapsibleError, - tab: firstTabWithErrors[0] || MultiHttpFormTabs.Headers, - }; - } - - return false; -} - -// rudimentary tree traversal to find the first object with asked for key -function findPath(target: any, key: string, existingPath = ``): string | null { - if (Array.isArray(target)) { - for (let i = 0; i < target.length; i++) { - let path = findPath(target[i], key, `${existingPath}.${i}.`); - - if (path) { - return path; - } - } - } - - if (target !== null && typeof target === 'object') { - if (target.hasOwnProperty(key)) { - return existingPath; - } - for (let k in target) { - let path = findPath(target[k], key, existingPath ? `${existingPath}.${k}` : k); - - if (path) { - return path; - } - } + if (Array.isArray(entries) && entries.length > 0) { + return entries.map((_, index) => index).filter((entry) => entry !== undefined); } return null; } - -export function focusField(buttonRef: HTMLButtonElement | null, id: string) { - requestAnimationFrame(() => { - buttonRef?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - - requestAnimationFrame(() => { - const inputEl = document.querySelector(`[name="${id}"]`); - - if (inputEl instanceof HTMLInputElement) { - if (inputEl.type === 'hidden') { - const focussableInput = inputEl?.parentElement?.querySelector(`input`); - return focussableInput?.focus({ preventScroll: true }); - } - - inputEl.focus({ preventScroll: true }); - } - }); - }); -} diff --git a/src/components/MultiHttp/MultiHttpTypes.ts b/src/components/MultiHttp/MultiHttpTypes.ts index 968831d5d..ba1c9c455 100644 --- a/src/components/MultiHttp/MultiHttpTypes.ts +++ b/src/components/MultiHttp/MultiHttpTypes.ts @@ -1,13 +1,5 @@ import { HttpMethod, Label, MultiHttpAssertionType } from 'types'; -export enum MultiHttpFormTabs { - Headers = 'headers', - QueryParams = 'query', - Assertions = 'checks', - Body = 'body', - Variables = 'variables', -} - export type MultiHttpVariable = { type: number; name: string; diff --git a/src/components/ProbeCard/ProbeCard.test.tsx b/src/components/ProbeCard/ProbeCard.test.tsx index ea9eecf23..69386692d 100644 --- a/src/components/ProbeCard/ProbeCard.test.tsx +++ b/src/components/ProbeCard/ProbeCard.test.tsx @@ -5,7 +5,7 @@ import { render } from 'test/render'; import { runTestAsViewer } from 'test/utils'; import { ROUTES } from 'types'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { ProbeCard } from './ProbeCard'; diff --git a/src/components/ProbeCard/ProbeCard.tsx b/src/components/ProbeCard/ProbeCard.tsx index 731ccde20..f57ad08f8 100644 --- a/src/components/ProbeCard/ProbeCard.tsx +++ b/src/components/ProbeCard/ProbeCard.tsx @@ -7,7 +7,7 @@ import { type Label, type Probe, ROUTES } from 'types'; import { canEditProbes } from 'utils'; import { Card } from 'components/Card'; import { SuccessRateGaugeProbe } from 'components/Gauges'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; export const ProbeCard = ({ probe }: { probe: Probe }) => { const [isFocused, setIsFocused] = useState(false); diff --git a/src/components/ProbeEditor/ProbeEditor.test.tsx b/src/components/ProbeEditor/ProbeEditor.test.tsx index ff9e62c76..7f8ca0206 100644 --- a/src/components/ProbeEditor/ProbeEditor.test.tsx +++ b/src/components/ProbeEditor/ProbeEditor.test.tsx @@ -5,7 +5,7 @@ import { render } from 'test/render'; import { fillProbeForm, runTestAsViewer, UPDATED_VALUES } from 'test/utils'; import { ROUTES } from 'types'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { TEMPLATE_PROBE } from 'page/NewProbe'; import { ProbeEditor } from './ProbeEditor'; diff --git a/src/components/ProbeEditor/ProbeEditor.tsx b/src/components/ProbeEditor/ProbeEditor.tsx index 651d5ee52..7699b8b35 100644 --- a/src/components/ProbeEditor/ProbeEditor.tsx +++ b/src/components/ProbeEditor/ProbeEditor.tsx @@ -11,7 +11,7 @@ import { canEditProbes } from 'utils'; import { HorizontalCheckboxField } from 'components/HorizonalCheckboxField'; import { LabelField } from 'components/LabelField'; import { ProbeRegionsSelect } from 'components/ProbeRegionsSelect'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { SimpleMap } from 'components/SimpleMap'; type ProbeEditorProps = { diff --git a/src/components/Request/RequestOptions.tsx b/src/components/Request/RequestOptions.tsx index 5bdae36e7..5c103646f 100644 --- a/src/components/Request/RequestOptions.tsx +++ b/src/components/Request/RequestOptions.tsx @@ -1,66 +1,67 @@ -import React, { Children, Fragment, isValidElement, ReactNode, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui'; -import { css } from '@emotion/css'; +import React, { Children, forwardRef, Fragment, isValidElement, ReactNode, useImperativeHandle, useState } from 'react'; +import { Button, Stack, Tab, TabContent, TabsBar } from '@grafana/ui'; +import { HandleErrorRef } from 'hooks/useNestedRequestErrors'; import { Indent } from 'components/Indent'; -export const RequestOptions = ({ children }: { children: ReactNode }) => { - const [open, setOpen] = useState(false); +interface RequestOptionsProps { + children: ReactNode; + open?: boolean; +} + +export const RequestOptions = forwardRef(({ children, open }, handleErrorRef) => { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState(0); + + useImperativeHandle(handleErrorRef, () => ({ + openOptions: () => setIsOpen(true), + goToTab: (index: number) => setActiveTab(index), + })); return (
- - {open && ( + {isOpen && ( - {children} - - )} -
- ); -}; + + + {Children.map(children, (section, i) => { + if (!isValidElement(section)) { + return null; + } -const RequestOptionsContent = ({ children }: { children: ReactNode }) => { - const styles = useStyles2(getStyles); - const [activeTab, setActiveTab] = useState(0); + return ( + setActiveTab(i)} + active={activeTab === i} + /> + ); + })} + + + {Children.map(children, (section, i) => { + if (!isValidElement(section)) { + return null; + } - return ( -
- - {Children.map(children, (section, i) => { - if (!isValidElement(section)) { - return null; - } - - return ( - setActiveTab(i)} - active={activeTab === i} - /> - ); - })} - - - {Children.map(children, (section, i) => { - if (!isValidElement(section)) { - return null; - } - - return activeTab === i && {section}; - })} - + return activeTab === i && {section}; + })} + + {' '} + + )}
); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - stackCol: css({ - display: `flex`, - flexDirection: `column`, - gap: theme.spacing(1), - }), }); + +RequestOptions.displayName = 'RequestOptions'; diff --git a/src/components/Routing.consts.ts b/src/components/Routing.consts.ts new file mode 100644 index 000000000..7cdf8a3dc --- /dev/null +++ b/src/components/Routing.consts.ts @@ -0,0 +1 @@ +export const PLUGIN_URL_PATH = '/a/grafana-synthetic-monitoring-app/'; diff --git a/src/components/Routing.test.tsx b/src/components/Routing.test.tsx index 51202ef8a..2dfba1aba 100644 --- a/src/components/Routing.test.tsx +++ b/src/components/Routing.test.tsx @@ -4,9 +4,10 @@ import { screen } from '@testing-library/react'; import { type CustomRenderOptions, render } from 'test/render'; import { ROUTES } from 'types'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; -import { PLUGIN_URL_PATH } from './constants'; -import { getRoute, Routing } from './Routing'; +import { Routing } from './Routing'; +import { getRoute } from './Routing.utils'; function renderRouting(options?: CustomRenderOptions) { return render(, options); @@ -85,7 +86,9 @@ describe('Renders specific welcome pages when app is not initializd', () => { }); renderRouting({ path: getRoute(ROUTES.Checks) }); - const text = await screen.findByText('Click the Create a Check button to initialize the plugin and create checks', { exact: false }); + const text = await screen.findByText('Click the Create a Check button to initialize the plugin and create checks', { + exact: false, + }); expect(text).toBeInTheDocument(); }); diff --git a/src/components/Routing.tsx b/src/components/Routing.tsx index 0d306dd1e..a77b199f3 100644 --- a/src/components/Routing.tsx +++ b/src/components/Routing.tsx @@ -19,10 +19,10 @@ import { SceneHomepage } from 'page/SceneHomepage'; import { UnprovisionedSetup } from 'page/UnprovisionedSetup'; import { WelcomePage } from 'page/WelcomePage'; +import { PLUGIN_URL_PATH } from './Routing.consts'; +import { getRoute } from './Routing.utils'; import { SceneRedirecter } from './SceneRedirecter'; -export const PLUGIN_URL_PATH = '/a/grafana-synthetic-monitoring-app/'; - export const Routing = ({ onNavChanged }: Pick) => { const queryParams = useQuery(); const navigate = useNavigation(); @@ -86,7 +86,3 @@ export const Routing = ({ onNavChanged }: Pick) => ); }; - -export function getRoute(route: ROUTES) { - return `/a/grafana-synthetic-monitoring-app/${route}`; -} diff --git a/src/components/Routing.utils.ts b/src/components/Routing.utils.ts new file mode 100644 index 000000000..0619e3303 --- /dev/null +++ b/src/components/Routing.utils.ts @@ -0,0 +1,7 @@ +import { ROUTES } from 'types'; + +import { PLUGIN_URL_PATH } from './Routing.consts'; + +export function getRoute(route: ROUTES) { + return `${PLUGIN_URL_PATH}${route}`; +} diff --git a/src/components/SceneRedirecter.tsx b/src/components/SceneRedirecter.tsx index 6c2f0931b..afa73be08 100644 --- a/src/components/SceneRedirecter.tsx +++ b/src/components/SceneRedirecter.tsx @@ -6,7 +6,7 @@ import { ROUTES } from 'types'; import { useChecks } from 'data/useChecks'; import { useQuery } from 'hooks/useQuery'; -import { PLUGIN_URL_PATH } from './constants'; +import { PLUGIN_URL_PATH } from './Routing.consts'; export function SceneRedirecter() { const queryParams = useQuery(); diff --git a/src/components/TLSConfig.tsx b/src/components/TLSConfig.tsx index 9dd5c6ae6..aaea1bd57 100644 --- a/src/components/TLSConfig.tsx +++ b/src/components/TLSConfig.tsx @@ -4,15 +4,18 @@ import { Container, Field, Input, TextArea } from '@grafana/ui'; import { get } from 'lodash'; import { TLSConfigFields } from './CheckEditor/CheckEditor.types'; -import { CheckFormValues } from 'types'; +import { CheckFormValues, CheckFormValuesGRPC, CheckFormValuesHttp, CheckFormValuesTcp } from 'types'; import { HorizontalCheckboxField } from 'components/HorizonalCheckboxField'; -interface TLSConfigProps { +interface TLSConfigProps { disabled?: boolean; - fields: TLSConfigFields; + fields: TLSConfigFields; } -export const TLSConfig = ({ disabled, fields }: TLSConfigProps) => { +export const TLSConfig = ({ + disabled, + fields, +}: TLSConfigProps) => { const { register, formState: { errors }, diff --git a/src/components/constants.ts b/src/components/constants.ts index 7805dbf9b..c53d42dc1 100644 --- a/src/components/constants.ts +++ b/src/components/constants.ts @@ -431,8 +431,6 @@ export const HTTP_COMPRESSION_ALGO_OPTIONS = [ { label: 'deflate', value: HTTPCompressionAlgo.deflate }, ]; -export const PLUGIN_URL_PATH = '/a/grafana-synthetic-monitoring-app/'; - export const METHOD_OPTIONS = [ { label: 'GET', @@ -504,4 +502,4 @@ export const LATENCY_DESCRIPTION = export const STANDARD_REFRESH_INTERVAL = 1000 * 60; -export const CHECK_FORM_ERROR_EVENT = `sm-form-error`; +export const CHECK_FORM_ERROR_EVENT = `sm-check-form-error`; diff --git a/src/hooks/useAppInitializer.ts b/src/hooks/useAppInitializer.ts index 1e8d0db1c..bab4bccd8 100644 --- a/src/hooks/useAppInitializer.ts +++ b/src/hooks/useAppInitializer.ts @@ -8,7 +8,7 @@ import { FaroEvent, reportError, reportEvent } from 'faro'; import { initializeDatasource } from 'utils'; import { InstanceContext } from 'contexts/InstanceContext'; import { LEGACY_LOGS_DS_NAME, LEGACY_METRICS_DS_NAME } from 'components/constants'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; interface InitializeProps { metricsSettings: DataSourceInstanceSettings; diff --git a/src/hooks/useCheckTypeGroupOptions.tsx b/src/hooks/useCheckTypeGroupOptions.tsx index 48b0dd484..852890564 100644 --- a/src/hooks/useCheckTypeGroupOptions.tsx +++ b/src/hooks/useCheckTypeGroupOptions.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { IconName } from '@grafana/data'; import { CheckType, CheckTypeGroup, ROUTES } from 'types'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { CHECK_TYPE_OPTIONS, useCheckTypeOptions } from './useCheckTypeOptions'; diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index 82c8c2507..5a74d8a8c 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { getLocationSrv } from '@grafana/runtime'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; export type QueryParamMap = { [key: string]: string; diff --git a/src/hooks/useNestedRequestErrors.ts b/src/hooks/useNestedRequestErrors.ts new file mode 100644 index 000000000..fb0659f15 --- /dev/null +++ b/src/hooks/useNestedRequestErrors.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { FieldErrors } from 'react-hook-form'; + +import { CheckFormInvalidSubmissionEvent, CheckFormValues } from 'types'; +import { RequestFields } from 'components/CheckEditor/CheckEditor.types'; +import { flattenKeys } from 'components/CheckForm/checkForm.utils'; +import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; + +export interface HandleErrorRef { + openOptions: () => void; + goToTab: (index: number) => void; +} + +export function useNestedRequestErrors(fields: RequestFields) { + const handleErrorRef = useRef(null); + + useEffect(() => { + const onErrorEvent = (e: CustomEvent) => { + const { errs } = e.detail; + const res = hasNestedErrors(fields, errs); + + if (res.length) { + requestAnimationFrame(() => { + handleErrorRef.current?.openOptions(); + handleErrorRef.current?.goToTab(res[0]); + }); + } + }; + + document.addEventListener(CHECK_FORM_ERROR_EVENT, onErrorEvent); + + return () => { + document.removeEventListener(CHECK_FORM_ERROR_EVENT, onErrorEvent); + }; + }, [fields]); + + return { handleErrorRef }; +} + +function hasNestedErrors(fields: RequestFields, errors: FieldErrors) { + const errorKeys = flattenKeys(errors); + const errorFields = fieldsSectionMap(fields, errorKeys); + + return Object.values(errorFields); +} + +function fieldsSectionMap(fields: RequestFields, errorKeys: string[]): Record { + return Object.values(fields) + .filter((field) => field.section !== undefined) + .reduce((acc, { name, section }) => { + const matcher = errorKeys.find((error) => error.startsWith(name)); + + if (matcher) { + return { + ...acc, + [name]: section, + }; + } + + return acc; + }, {}); +} diff --git a/src/index.d.ts b/src/index.d.ts index e08a224a2..39efe7b14 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -5,7 +5,7 @@ declare module 'body-parser'; import { FieldErrors } from 'react-hook-form'; import {} from '@emotion/core'; -import { CheckFormValues } from 'types'; +import { CheckFormInvalidSubmissionEvent, CheckFormValues } from 'types'; import { CHECK_FORM_ERROR_EVENT } from 'components/constants'; // This is a monkey patch of the default Object.keys() typing that casts the return type to be a keyof the original object, instead of a string. https://fettblog.eu/typescript-better-object-keys/ @@ -22,7 +22,7 @@ interface ObjectConstructor { } interface CustomEventMap { - [CHECK_FORM_ERROR_EVENT]: CustomEvent>; + [CHECK_FORM_ERROR_EVENT]: CustomEvent; } declare global { diff --git a/src/page/CheckRouter.test.tsx b/src/page/CheckRouter.test.tsx new file mode 100644 index 000000000..120efcd7f --- /dev/null +++ b/src/page/CheckRouter.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import { render } from 'test/render'; + +import { CheckType, CheckTypeGroup, ROUTES } from 'types'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; + +import { CheckRouter } from './CheckRouter'; + +describe(``, () => { + it(`should redirect from the old add new check route to the new one`, async () => { + const checkType = CheckType.HTTP; + + const { history } = render(, { + path: `${PLUGIN_URL_PATH}${ROUTES.Checks}/new/${checkType}`, + route: `${PLUGIN_URL_PATH}${ROUTES.Checks}`, + }); + + await waitFor(() => + expect(history.location.pathname).toBe(`${PLUGIN_URL_PATH}${ROUTES.Checks}/new/${CheckTypeGroup.ApiTest}`) + ); + expect(history.location.search).toBe(`?checkType=${checkType}`); + }); + + it(`should redirect from the old edit check route to the new one`, async () => { + const checkType = CheckType.HTTP; + const checkID = 1; + + const { history } = render(, { + path: `${PLUGIN_URL_PATH}${ROUTES.Checks}/edit/${checkType}/${checkID}`, + route: `${PLUGIN_URL_PATH}${ROUTES.Checks}`, + }); + + await waitFor(() => + expect(history.location.pathname).toBe( + `${PLUGIN_URL_PATH}${ROUTES.Checks}/edit/${CheckTypeGroup.ApiTest}/${checkID}` + ) + ); + }); +}); diff --git a/src/page/CheckRouter.tsx b/src/page/CheckRouter.tsx index 4fbe679da..cc34a821a 100644 --- a/src/page/CheckRouter.tsx +++ b/src/page/CheckRouter.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Route, Switch, useRouteMatch } from 'react-router-dom'; +import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; +import { CHECK_TYPE_OPTIONS } from 'hooks/useCheckTypeOptions'; import { CheckList } from 'components/CheckList'; import { ChooseCheckGroup } from 'components/ChooseCheckGroup'; @@ -13,6 +14,21 @@ export function CheckRouter() { return ( + {NEW_CHECK_REDIRECTS.map(({ from, to }) => ( + + + + ))} + {EDIT_CHECK_REDIRECTS.map(({ from, to }) => ( + + {(props) => { + return ; + }} + + ))} + + + @@ -31,3 +47,13 @@ export function CheckRouter() { ); } + +const NEW_CHECK_REDIRECTS = CHECK_TYPE_OPTIONS.map(({ group, value }) => ({ + from: `/new/${value}`, + to: `/new/${group}?checkType=${value}`, +})); + +const EDIT_CHECK_REDIRECTS = CHECK_TYPE_OPTIONS.map(({ group, value }) => ({ + from: `/edit/${value}/:id`, + to: `/edit/${group}`, +})); diff --git a/src/page/ChecksPage.test.tsx b/src/page/ChecksPage.test.tsx index ccb5cba22..83fbf39f5 100644 --- a/src/page/ChecksPage.test.tsx +++ b/src/page/ChecksPage.test.tsx @@ -8,7 +8,7 @@ import { render } from 'test/render'; import { server } from 'test/server'; import { AlertSensitivity, Check, CheckTypeGroup, ROUTES } from 'types'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; import { CheckRouter } from './CheckRouter'; diff --git a/src/page/DashboardPage.tsx b/src/page/DashboardPage.tsx index ad60bf643..a487fc143 100644 --- a/src/page/DashboardPage.tsx +++ b/src/page/DashboardPage.tsx @@ -7,7 +7,7 @@ import { CheckPageParams, CheckType, DashboardSceneAppConfig } from 'types'; import { getCheckType } from 'utils'; import { InstanceContext } from 'contexts/InstanceContext'; import { useChecks } from 'data/useChecks'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; import { getDNSScene } from 'scenes/DNS'; import { getGRPCScene } from 'scenes/GRPC/getGRPCScene'; import { getHTTPScene } from 'scenes/HTTP'; diff --git a/src/page/EditCheck/EditCheck.tsx b/src/page/EditCheck/EditCheck.tsx index 62bf0320a..5cc01023d 100644 --- a/src/page/EditCheck/EditCheck.tsx +++ b/src/page/EditCheck/EditCheck.tsx @@ -6,7 +6,7 @@ import { CheckPageParams, ROUTES } from 'types'; import { useChecks } from 'data/useChecks'; import { useNavigation } from 'hooks/useNavigation'; import { CheckForm } from 'components/CheckForm/CheckForm'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; export const EditCheck = () => { return ; diff --git a/src/page/EditProbe/EditProbe.test.tsx b/src/page/EditProbe/EditProbe.test.tsx index 8da65eea3..f54e55b2b 100644 --- a/src/page/EditProbe/EditProbe.test.tsx +++ b/src/page/EditProbe/EditProbe.test.tsx @@ -7,7 +7,7 @@ import { server } from 'test/server'; import { Probe, ROUTES } from 'types'; import { formatDate } from 'utils'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { EditProbe } from './EditProbe'; diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.payload.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.payload.test.tsx index 59a5e9711..510e0a905 100644 --- a/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.payload.test.tsx +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.payload.test.tsx @@ -8,7 +8,7 @@ import { fillMandatoryFields } from '../../../../__testHelpers__/apiEndPoint'; const checkType = CheckType.DNS; -describe(`MultiHttpCheck - Section 1 (Request) payload`, () => { +describe(`DNSCheck - Section 1 (Request) payload`, () => { it(`has the correct default values submitted`, async () => { const { read, user } = await renderNewForm(checkType); await fillMandatoryFields({ user, checkType }); diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.ui.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.ui.test.tsx new file mode 100644 index 000000000..28a234531 --- /dev/null +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/DNSCheck/1-request.ui.test.tsx @@ -0,0 +1,25 @@ +import { screen } from '@testing-library/react'; + +import { CheckType } from 'types'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +import { fillMandatoryFields } from '../../../../__testHelpers__/apiEndPoint'; + +const checkType = CheckType.DNS; + +describe(`DNSCheck - Section 1 (Request) UI`, () => { + it(`will navigate to the first section and open the request to reveal a nested error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('DNS Settings')); + + const serverInputPreSubmit = screen.getByLabelText('Server'); + await user.clear(serverInputPreSubmit); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const err = await screen.findByText(`DNS server is required`, { exact: false }); + expect(err).toBeInTheDocument(); + }); +}); diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/HTTPCheck/1-request.ui.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/HTTPCheck/1-request.ui.test.tsx index 191c4cd3b..f12568579 100644 --- a/src/page/NewCheck/__tests__/ApiEndPointChecks/HTTPCheck/1-request.ui.test.tsx +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/HTTPCheck/1-request.ui.test.tsx @@ -2,7 +2,8 @@ import { screen, within } from '@testing-library/react'; import { getSelect } from 'test/utils'; import { CheckType, HttpMethod } from 'types'; -import { renderNewForm } from 'page/__testHelpers__/checkForm'; +import { fillMandatoryFields } from 'page/__testHelpers__/apiEndPoint'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; const checkType = CheckType.HTTP; @@ -20,4 +21,16 @@ describe(`HttpCheck - Section 1 (Request) UI`, () => { const [methodSelect] = await getSelect({ label: 'Request method' }); expect(within(methodSelect).getByText(HttpMethod.GET)).toBeInTheDocument(); }); + + it(`will navigate to the first section and open the request to reveal a nested error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('Add request header')); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const err = await screen.findByText(`Request header name is required`); + expect(err).toBeInTheDocument(); + }); }); diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/TCPCheck/1-request.ui.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/TCPCheck/1-request.ui.test.tsx new file mode 100644 index 000000000..015d96a6a --- /dev/null +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/TCPCheck/1-request.ui.test.tsx @@ -0,0 +1,25 @@ +import { screen } from '@testing-library/react'; + +import { CheckType } from 'types'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +import { fillMandatoryFields } from '../../../../__testHelpers__/apiEndPoint'; + +const checkType = CheckType.TCP; + +describe(`TCPCheck - Section 1 (Request) UI`, () => { + it(`will navigate to the first section and open the request to reveal a nested error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('TLS Config')); + + const certInputPreSubmit = screen.getByLabelText('CA certificate', { exact: false }); + await user.type(certInputPreSubmit, `not a cert`); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const err = await screen.findByText(`Certificate must be in the PEM format.`); + expect(err).toBeInTheDocument(); + }); +}); diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/TracerouteCheck/1-request.ui.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/TracerouteCheck/1-request.ui.test.tsx new file mode 100644 index 000000000..ba5c3cf91 --- /dev/null +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/TracerouteCheck/1-request.ui.test.tsx @@ -0,0 +1,24 @@ +import { screen } from '@testing-library/react'; + +import { CheckType } from 'types'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +import { fillMandatoryFields } from '../../../../__testHelpers__/apiEndPoint'; + +const checkType = CheckType.Traceroute; + +describe(`TracerouteCheck - Section 1 (Request) UI`, () => { + it(`will navigate to the first section and open the request to reveal a nested error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + + const unknownHopsInputPreSubmit = screen.getByLabelText('Max unknown hops', { exact: false }); + await user.clear(unknownHopsInputPreSubmit); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const err = await screen.findByText(`Must be a number (0-20)`); + expect(err).toBeInTheDocument(); + }); +}); diff --git a/src/page/NewCheck/__tests__/ApiEndPointChecks/gRPCCheck/1-request.ui.test.tsx b/src/page/NewCheck/__tests__/ApiEndPointChecks/gRPCCheck/1-request.ui.test.tsx new file mode 100644 index 000000000..6453aafc5 --- /dev/null +++ b/src/page/NewCheck/__tests__/ApiEndPointChecks/gRPCCheck/1-request.ui.test.tsx @@ -0,0 +1,25 @@ +import { screen } from '@testing-library/react'; + +import { CheckType } from 'types'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +import { fillMandatoryFields } from '../../../../__testHelpers__/apiEndPoint'; + +const checkType = CheckType.GRPC; + +describe(`gRPCCheck - Section 1 (Request) UI`, () => { + it(`will navigate to the first section and open the request to reveal a nested error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('TLS Config')); + + const certInputPreSubmit = screen.getByLabelText('CA certificate', { exact: false }); + await user.type(certInputPreSubmit, `not a cert`); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const err = await screen.findByText(`Certificate must be in the PEM format.`); + expect(err).toBeInTheDocument(); + }); +}); diff --git a/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.payload.test.tsx b/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.payload.test.tsx new file mode 100644 index 000000000..e48801283 --- /dev/null +++ b/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.payload.test.tsx @@ -0,0 +1,265 @@ +import { screen } from '@testing-library/react'; +import { selectOption } from 'test/utils'; + +import { CheckType, HttpMethod, MultiHttpVariableType } from 'types'; +import { toBase64 } from 'utils'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +import { fillMandatoryFields } from '../../../../__testHelpers__/multiStep'; + +const checkType = CheckType.MULTI_HTTP; + +describe(`MultiHTTPCheck - Section 1 (Request) payload`, () => { + describe(`Single request`, () => { + it(`has the correct default values submitted`, async () => { + const { read, user } = await renderNewForm(checkType); + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.method).toBe(HttpMethod.GET); + }); + + it(`can add entry request target`, async () => { + const REQUEST_TARGET = `https://example.com`; + + const { read, user } = await renderNewForm(checkType); + const targetInput = await screen.findByLabelText('Request target for request 1 *', { + selector: `input`, + exact: false, + }); + await user.type(targetInput, REQUEST_TARGET); + + await fillMandatoryFields({ user, fieldsToOmit: [`target`], checkType }); + await submitForm(user); + + const { body } = await read(); + expect(body.settings.multihttp.entries[0].request.url).toBe(REQUEST_TARGET); + }); + + describe(`Request options`, () => { + it(`can submit request headers`, async () => { + const HEADER_KEY_1 = `header-key-1`; + const HEADER_VALUE_1 = `header-value-1`; + const HEADER_KEY_2 = `header-key-2`; + const HEADER_VALUE_2 = `header-value-2`; + + const { read, user } = await renderNewForm(checkType); + + await user.click(screen.getByText('Request options')); + const addRequestHeaderButton = screen.getByText(`Add request header`, { exact: false }); + await user.click(addRequestHeaderButton); + + const headerKeyInput = await screen.findByLabelText('Request header 1 name'); + const headerValueInput = await screen.findByLabelText('Request header 1 value'); + + await user.type(headerKeyInput, HEADER_KEY_1); + await user.type(headerValueInput, HEADER_VALUE_1); + await user.click(addRequestHeaderButton); + + const headerKeyInput2 = await screen.findByLabelText('Request header 2 name'); + const headerValueInput2 = await screen.findByLabelText('Request header 2 value'); + + await user.type(headerKeyInput2, HEADER_KEY_2); + await user.type(headerValueInput2, HEADER_VALUE_2); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.headers).toEqual([ + { name: HEADER_KEY_1, value: HEADER_VALUE_1 }, + { name: HEADER_KEY_2, value: HEADER_VALUE_2 }, + ]); + }); + + it(`can submit query parameters`, async () => { + const QUERYPARAM_KEY_1 = `query-key-1`; + const QUERYPARAM_VALUE_1 = `query-value-1`; + const QUERYPARAM_KEY_2 = `query-key-2`; + const QUERYPARAM_VALUE_2 = `query-value-2`; + + const { read, user } = await renderNewForm(checkType); + + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('Query Parameters')); + const addQueryParamButton = screen.getByText(`Add query parameter`, { exact: false }); + await user.click(addQueryParamButton); + + const headerKeyInput = await screen.findByLabelText('Query parameter 1 name'); + const headerValueInput = await screen.findByLabelText('Query parameter 1 value'); + + await user.type(headerKeyInput, QUERYPARAM_KEY_1); + await user.type(headerValueInput, QUERYPARAM_VALUE_1); + await user.click(addQueryParamButton); + + const queryParamKeyInput2 = await screen.findByLabelText('Query parameter 2 name'); + const queryParamValueInput2 = await screen.findByLabelText('Query parameter 2 value'); + + await user.type(queryParamKeyInput2, QUERYPARAM_KEY_2); + await user.type(queryParamValueInput2, QUERYPARAM_VALUE_2); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.queryFields).toEqual([ + { name: QUERYPARAM_KEY_1, value: QUERYPARAM_VALUE_1 }, + { name: QUERYPARAM_KEY_2, value: QUERYPARAM_VALUE_2 }, + ]); + }); + }); + + describe(`Request body`, () => { + it(`can submit the content type`, async () => { + const CONTENT_TYPE = 'application/json'; + + const { read, user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('Request Body')); + await user.type(screen.getByLabelText('Content type', { exact: false }), CONTENT_TYPE); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.body.contentType).toBe(CONTENT_TYPE); + }); + + it(`can submit the content encoding`, async () => { + const CONTENT_ENCODING = 'gzip'; + + const { read, user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('Request Body')); + await user.type(screen.getByLabelText('Content encoding', { exact: false }), CONTENT_ENCODING); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.body.contentEncoding).toBe(CONTENT_ENCODING); + }); + + it(`can submit the request body`, async () => { + const REQUEST_BODY = 'some request body'; + + const { read, user } = await renderNewForm(checkType); + await user.click(screen.getByText('Request options')); + await user.click(screen.getByText('Request Body')); + await user.type(screen.getByLabelText('Request Body', { selector: `textarea`, exact: false }), REQUEST_BODY); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].request.body.payload).toBe(toBase64(REQUEST_BODY)); + }); + }); + + describe(`Set variables`, () => { + it(`can set a JSON path variable`, async () => { + const VAR_NAME = 'a lovely variable'; + const VAR_EXPRESSION = '$.json.path'; + + const { read, user } = await renderNewForm(checkType); + + await user.click(screen.getByText('Set variables')); + await user.click(screen.getByText('Add variable')); + + const variableNameInput = screen.getByLabelText('Variable name', { exact: false }); + await user.type(variableNameInput, VAR_NAME); + + await selectOption(user, { label: 'Variable type', option: 'JSON Path' }); + + const variableExpressionInput = screen.getByLabelText('Variable expression', { exact: false }); + await user.type(variableExpressionInput, VAR_EXPRESSION); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].variables).toEqual([ + { + name: VAR_NAME, + type: MultiHttpVariableType.JSON_PATH, + expression: VAR_EXPRESSION, + }, + ]); + }); + + it(`can set a Regular Expression variable`, async () => { + const VAR_NAME = 'a lovely variable'; + const VAR_EXPRESSION = 'some regex'; + + const { read, user } = await renderNewForm(checkType); + + await user.click(screen.getByText('Set variables')); + await user.click(screen.getByText('Add variable')); + + const variableNameInput = screen.getByLabelText('Variable name', { exact: false }); + await user.type(variableNameInput, VAR_NAME); + + await selectOption(user, { label: 'Variable type', option: 'Regular Expression' }); + + const variableExpressionInput = screen.getByLabelText('Variable expression', { exact: false }); + await user.type(variableExpressionInput, VAR_EXPRESSION); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].variables).toEqual([ + { + name: VAR_NAME, + type: MultiHttpVariableType.REGEX, + expression: VAR_EXPRESSION, + }, + ]); + }); + + it(`can set a CSS selector variable`, async () => { + const VAR_NAME = 'a lovely variable'; + const VAR_ATTRIBUTE = 'some attribute'; + const VAR_EXPRESSION = 'some regex'; + + const { read, user } = await renderNewForm(checkType); + + await user.click(screen.getByText('Set variables')); + await user.click(screen.getByText('Add variable')); + + const variableNameInput = screen.getByLabelText('Variable name', { exact: false }); + await user.type(variableNameInput, VAR_NAME); + + await selectOption(user, { label: 'Variable type', option: 'CSS Selector' }); + + const variableAttributeInput = screen.getByLabelText('Attribute', { exact: false }); + await user.type(variableAttributeInput, VAR_ATTRIBUTE); + + const variableExpressionInput = screen.getByLabelText('Variable expression', { exact: false }); + await user.type(variableExpressionInput, VAR_EXPRESSION); + + await fillMandatoryFields({ user, checkType }); + await submitForm(user); + + const { body } = await read(); + + expect(body.settings.multihttp.entries[0].variables).toEqual([ + { + name: VAR_NAME, + type: MultiHttpVariableType.CSS_SELECTOR, + expression: VAR_EXPRESSION, + attribute: VAR_ATTRIBUTE, + }, + ]); + }); + }); + }); +}); diff --git a/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.ui.test.tsx b/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.ui.test.tsx new file mode 100644 index 000000000..9988720a0 --- /dev/null +++ b/src/page/NewCheck/__tests__/MultiStepChecks/MultiHTTP/1-requests.ui.test.tsx @@ -0,0 +1,97 @@ +import { screen, within } from '@testing-library/react'; +import { DataTestIds } from 'test/dataTestIds'; + +import { CheckType } from 'types'; +import { goToSection, renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; + +const checkType = CheckType.MULTI_HTTP; + +describe(`MultiHTTPCheck - Section 1 (Requests) UI`, () => { + it(`can delete requests`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText(`Add request`)); + + const request = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-1`); + await user.click(within(request).getByLabelText('Remove request', { exact: false })); + expect(request).not.toBeInTheDocument(); + }); + + it(`will navigate to section 1 and open all requests with an error`, async () => { + const { user } = await renderNewForm(checkType); + await user.click(screen.getByText(`Add request`)); + await user.click(screen.getByText(`Add request`)); + await user.click(screen.getByText(`Add request`)); + + await goToSection(user, 2); + await submitForm(user); + + const errors = screen.getAllByText(`Target must be a valid web URL`); + + // original target + 3 added + expect(errors.length).toBe(4); + }); + + it(`will open all requests with errors, open the requests accordion and navigate to the first tab with an error`, async () => { + const { user } = await renderNewForm(checkType); + + // add empty header object to first request + const request1preSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-0`); + const request1Options = within(request1preSubmit).getByText(`Request options`); + await user.click(request1Options); + await user.click(within(request1preSubmit).getByText(`Add request header`, { exact: false })); + + // add valid request + await user.click(screen.getByText(`Add request`)); + const request2preSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-1`); + const target = within(request2preSubmit).getByLabelText(`Request target for request 2 *`); + await user.type(target, `https://grafana.com`); + + // add empty query params to third request + await user.click(screen.getByText(`Add request`)); + const request3preSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-2`); + const request3Options = within(request3preSubmit).getByText(`Request options`); + await user.click(request3Options); + await user.click(within(request3preSubmit).getByText(`Query Parameters`)); + await user.click(within(request3preSubmit).getByText(`Add query parameter`, { exact: false })); + + // navigate to the second section + await goToSection(user, 2); + await submitForm(user); + + const request1postSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-0`); + const request1NestedErr = await within(request1postSubmit).findByText(`Request header name is required`); + expect(request1NestedErr).toBeInTheDocument(); + + const request2postSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-1`); + const button = within(request2postSubmit).getByRole(`button`); + expect(button).toHaveAttribute(`aria-expanded`, `false`); + + const request3postSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-2`); + const request3NestedErr = await within(request3postSubmit).findByText(`Query parameter name is required`); + expect(request3NestedErr).toBeInTheDocument(); + }); + + it(`will open all requests and open the variables accordion when it has errors`, async () => { + const { user } = await renderNewForm(checkType); + const request1preSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-0`); + await user.click(within(request1preSubmit).getByText(`Set variables`)); + await user.click(within(request1preSubmit).getByText(`Add variable`, { exact: false })); + + // add second request + await user.click(screen.getByText(`Add request`)); + const request2preSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-1`); + await user.click(within(request2preSubmit).getByText(`Set variables`)); + await user.click(within(request2preSubmit).getByText(`Add variable`, { exact: false })); + + // navigate to the second section + await goToSection(user, 2); + await submitForm(user); + + const request1postSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-0`); + const request1Err = await within(request1postSubmit).findByText(`Name is required`); + expect(request1Err).toBeInTheDocument(); + + const request2postSubmit = screen.getByTestId(`${DataTestIds.MULTI_HTTP_REQUEST}-1`); + expect(within(request2postSubmit).getByText(`Name is required`)).toBeInTheDocument(); + }); +}); diff --git a/src/page/NewCheck/__tests__/NewCheck.test.tsx b/src/page/NewCheck/__tests__/NewCheck.test.tsx index 0cdb86caf..8882fe043 100644 --- a/src/page/NewCheck/__tests__/NewCheck.test.tsx +++ b/src/page/NewCheck/__tests__/NewCheck.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { apiRoute } from 'test/handlers'; import { server } from 'test/server'; @@ -84,6 +84,16 @@ describe(``, () => { expect(screen.getByRole(`button`, { name: /Submit/ })).toBeEnabled(); }); + it(`should focus the probes select correctly when appropriate`, async () => { + const { user } = await renderNewForm(CheckType.HTTP); + + await fillMandatoryFields({ user, checkType: CheckType.HTTP, fieldsToOmit: ['probes'] }); + await submitForm(user); + + const probesSelect = await screen.findByLabelText(/Probe locations/); + await waitFor(() => expect(probesSelect).toHaveFocus()); + }); + // jsdom doesn't give us back the submitter of the form, so we can't test this // https://github.com/jsdom/jsdom/issues/3117 it.skip(`should show an error message when it fails to test a check`, async () => {}); diff --git a/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx b/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx new file mode 100644 index 000000000..28ca819e6 --- /dev/null +++ b/src/page/NewCheck/__tests__/ScriptedChecks/Scripted/1-script.ui.test.tsx @@ -0,0 +1,39 @@ +import { screen, waitFor } from '@testing-library/react'; + +import { CheckType } from 'types'; +import { renderNewForm, submitForm } from 'page/__testHelpers__/checkForm'; +import { fillMandatoryFields } from 'page/__testHelpers__/scripted'; + +const checkType = CheckType.Scripted; + +describe(`ScriptedCheck - 1 (Script) UI`, () => { + // todo: this is proving to be flaky in the CI/CD. Will look into it at a later date and reenable + it.skip(`will change to the script tab when there is a script error on submission`, async () => { + const { user } = await renderNewForm(checkType); + + await user.type(screen.getByLabelText('Job name', { exact: false }), `Job`); + await user.type(screen.getByLabelText(`Instance`, { exact: false }), `Instance`); + await user.clear(screen.getByTestId(`code-editor`)); + + await user.click(screen.getByText(`Examples`)); + expect(screen.getByText(`Basic authentication`)).toBeInTheDocument(); + + await submitForm(user); + const err = await screen.findByText(`Script is required.`); + expect(err).toBeInTheDocument(); + }); + + it(`will display an error and focus the script field when it is missing`, async () => { + const { user } = await renderNewForm(checkType); + const scriptTextAreaPreSubmit = screen.getByTestId(`code-editor`); + await user.clear(scriptTextAreaPreSubmit); + await fillMandatoryFields({ user, fieldsToOmit: [`probes`], checkType }); + + await submitForm(user); + const err = await screen.findByText(`Script is required.`); + expect(err).toBeInTheDocument(); + + const scriptTextAreaPostSubmit = screen.getByTestId(`code-editor`); + await waitFor(() => expect(scriptTextAreaPostSubmit).toHaveFocus()); + }); +}); diff --git a/src/page/NewProbe/NewProbe.test.tsx b/src/page/NewProbe/NewProbe.test.tsx index fb8a6c6f4..993ec8cf6 100644 --- a/src/page/NewProbe/NewProbe.test.tsx +++ b/src/page/NewProbe/NewProbe.test.tsx @@ -5,7 +5,7 @@ import { render } from 'test/render'; import { fillProbeForm } from 'test/utils'; import { ROUTES } from 'types'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { NewProbe } from './NewProbe'; diff --git a/src/page/Probes/Probes.test.tsx b/src/page/Probes/Probes.test.tsx index 1a5743697..1ab6ce249 100644 --- a/src/page/Probes/Probes.test.tsx +++ b/src/page/Probes/Probes.test.tsx @@ -5,7 +5,7 @@ import { DEFAULT_PROBES, PRIVATE_PROBE, PUBLIC_PROBE } from 'test/fixtures/probe import { render } from 'test/render'; import { ROUTES } from 'types'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; import { Probes } from './Probes'; diff --git a/src/page/Probes/Probes.tsx b/src/page/Probes/Probes.tsx index 1c2ad5156..9352ecf97 100644 --- a/src/page/Probes/Probes.tsx +++ b/src/page/Probes/Probes.tsx @@ -12,7 +12,7 @@ import { DocsLink } from 'components/DocsLink'; import { PluginPage } from 'components/PluginPage'; import { ProbeList } from 'components/ProbeList'; import { QueryErrorBoundary } from 'components/QueryErrorBoundary'; -import { getRoute } from 'components/Routing'; +import { getRoute } from 'components/Routing.utils'; export const Probes = () => { const theme = useTheme2(); diff --git a/src/page/SceneHomepage.tsx b/src/page/SceneHomepage.tsx index 2f032ef13..d5f6a66a7 100644 --- a/src/page/SceneHomepage.tsx +++ b/src/page/SceneHomepage.tsx @@ -5,7 +5,7 @@ import { LoadingPlaceholder } from '@grafana/ui'; import { DashboardSceneAppConfig, ROUTES } from 'types'; import { InstanceContext } from 'contexts/InstanceContext'; import { useChecks } from 'data/useChecks'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; import { getSummaryScene } from 'scenes/Summary'; export const SceneHomepage = () => { diff --git a/src/page/__testHelpers__/checkForm.tsx b/src/page/__testHelpers__/checkForm.tsx index 4316a24c7..b1a47c2d9 100644 --- a/src/page/__testHelpers__/checkForm.tsx +++ b/src/page/__testHelpers__/checkForm.tsx @@ -8,7 +8,7 @@ import { server } from 'test/server'; import { Check, CheckType, ROUTES } from 'types'; import { getCheckType, getCheckTypeGroup } from 'utils'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; import { EditCheck } from 'page/EditCheck'; import { NewCheck } from 'page/NewCheck'; diff --git a/src/page/__testHelpers__/scripted.ts b/src/page/__testHelpers__/scripted.ts index 612cfd034..3ed6b846b 100644 --- a/src/page/__testHelpers__/scripted.ts +++ b/src/page/__testHelpers__/scripted.ts @@ -17,12 +17,12 @@ export async function fillMandatoryFields({ user, fieldsToOmit = [], checkType } await goToSection(user, 1); if (!fieldsToOmit.includes('job')) { - const jobNameInput = await screen.findByLabelText('Job name', { exact: false }); + const jobNameInput = screen.getByLabelText('Job name', { exact: false }); await user.type(jobNameInput, `MANDATORY JOB NAME`); } if (!fieldsToOmit.includes('target')) { - const targetInput = await screen.getByLabelText(`Instance`, { exact: false }); + const targetInput = screen.getByLabelText(`Instance`, { exact: false }); await user.type(targetInput, TARGET_MAP[checkType]); } diff --git a/src/page/pageDefinitions.ts b/src/page/pageDefinitions.ts index d77c0361e..4a367e524 100644 --- a/src/page/pageDefinitions.ts +++ b/src/page/pageDefinitions.ts @@ -2,7 +2,7 @@ import React from 'react'; import { AppRootProps, NavModelItem } from '@grafana/data'; import { Settings } from 'types'; -import { PLUGIN_URL_PATH } from 'components/constants'; +import { PLUGIN_URL_PATH } from 'components/Routing.consts'; export type PageDefinition = { component: React.FC>; diff --git a/src/scenes/Common/editButton.tsx b/src/scenes/Common/editButton.tsx index c3ecc6925..e6b1b64fc 100644 --- a/src/scenes/Common/editButton.tsx +++ b/src/scenes/Common/editButton.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { SceneReactObject, SceneVariable } from '@grafana/scenes'; -import { Button, Spinner } from '@grafana/ui'; +import { SceneReactObject, SceneVariable, VariableValue } from '@grafana/scenes'; +import { LinkButton } from '@grafana/ui'; -import { ROUTES } from 'types'; -import { getCheckType } from 'utils'; +import { Check, ROUTES } from 'types'; +import { getCheckType, getCheckTypeGroup } from 'utils'; import { useChecks } from 'data/useChecks'; -import { useNavigation } from 'hooks/useNavigation'; +import { getRoute } from 'components/Routing.utils'; interface Props { job: SceneVariable; @@ -14,25 +14,28 @@ interface Props { function EditCheckButton({ job, instance }: Props) { const { data: checks = [], isLoading } = useChecks(); - const navigate = useNavigation(); + const url = getUrl(checks, instance.getValue(), job.getValue()); + return ( - + + Edit check + ); } +function getUrl(checks: Check[], target?: VariableValue | null, job?: VariableValue | null) { + const check = checks.find((check) => check.target === target && check.job === job); + + if (!check) { + return undefined; + } + + const checkType = getCheckType(check.settings); + const checkTypeGroup = getCheckTypeGroup(checkType); + + return `${getRoute(ROUTES.EditCheck)}/${checkTypeGroup}/${check.id}`; +} + export function getEditButton({ job, instance }: Props) { return new SceneReactObject({ component: EditCheckButton, diff --git a/src/schemas/forms/TracerouteCheckSchema.ts b/src/schemas/forms/TracerouteCheckSchema.ts index 8d000e49e..5bf91b9c4 100644 --- a/src/schemas/forms/TracerouteCheckSchema.ts +++ b/src/schemas/forms/TracerouteCheckSchema.ts @@ -9,9 +9,18 @@ const MAX_HOPS = 64; const MAX_UNKNOWN_HOPS = 20; const TracerouteSettingsSchema: ZodType = z.object({ - maxHops: z.number().min(0, `Must be greater than 0`).max(MAX_HOPS, `Can be no more than ${MAX_HOPS} hops`), + maxHops: z + .number({ + required_error: `Must be a number (0-${MAX_HOPS})`, + invalid_type_error: `Must be a number (0-${MAX_HOPS})`, + }) + .min(0, `Must be greater than 0`) + .max(MAX_HOPS, `Can be no more than ${MAX_HOPS} hops`), maxUnknownHops: z - .number() + .number({ + required_error: `Must be a number (0-${MAX_UNKNOWN_HOPS})`, + invalid_type_error: `Must be a number (0-${MAX_UNKNOWN_HOPS})`, + }) .min(0, `Must be greater than 0`) .max(MAX_UNKNOWN_HOPS, `Can be no more than ${MAX_UNKNOWN_HOPS} hops`), ptrLookup: z.boolean(), diff --git a/src/types.ts b/src/types.ts index 952651e38..11a12a668 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { SubmitErrorHandler, SubmitHandler } from 'react-hook-form'; +import { FieldErrors, SubmitErrorHandler, SubmitHandler } from 'react-hook-form'; import { DataSourceSettings, OrgRole, SelectableValue } from '@grafana/data'; import { EmbeddedScene, SceneRouteMatch } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; @@ -823,3 +823,8 @@ export interface TLSFormValues extends CheckFormValuesBase { }; }; } + +export interface CheckFormInvalidSubmissionEvent { + errs: FieldErrors; + source: string; +}