From 8f65e36b068ef1f5eb9871ff553912b4af5ad231 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 5 Feb 2024 15:14:08 +0100 Subject: [PATCH] Alerting: Show warning when cp does not exist and invalidate the form (#81621) * Show warning when cp does not exist and invalidate the form * Set error in contact selectedContactPoint form field manually * use RHF validate and trigger * Fix defaultvalue not being set in contact point and update the error message text * Simplify refetchReceivers prop definition in ContactPointSelectorProps --------- Co-authored-by: Gilles De Mey --- .../simplifiedRouting/AlertManagerRouting.tsx | 81 ++----- .../contactPoint/ContactPointSelector.tsx | 203 ++++++++++++++---- 2 files changed, 172 insertions(+), 112 deletions(-) diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index ad8151d4ebe..4ec14cd359a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -1,11 +1,11 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, CollapsableSection, IconButton, LoadingPlaceholder, Stack, TextLink, useStyles2 } from '@grafana/ui'; +import { Alert, CollapsableSection, LoadingPlaceholder, Stack, useStyles2 } from '@grafana/ui'; import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; -import { createUrl } from 'app/features/alerting/unified/utils/url'; +import { ContactPointReceiverSummary } from '../../../contact-points/ContactPoints'; import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints'; import { ContactPointWithMetadata } from '../../../contact-points/utils'; @@ -18,8 +18,6 @@ interface AlertManagerManualRoutingProps { alertManager: AlertManagerDataSource; } -const LOADING_SPINNER_DURATION = 1000; - export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRoutingProps) { const styles = useStyles2(getStyles); @@ -29,21 +27,17 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo ContactPointWithMetadata | undefined >(); - // We need to provide a fake loading state for the contact points, because it might be that the response is so fast that the loading spinner is not shown, - // and the user might think that the contact points are not fetched. - // We will show the loading spinner for 1 second, and if the fetching takes more than 1 second, we will show the loading spinner until the fetching is done. - - const [loadingContactPoints, setLoadingContactPoints] = useState(false); - // we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - const onClickRefresh = () => { - setLoadingContactPoints(true); - Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => { - setLoadingContactPoints(false); - }); + const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => { + setSelectedContactPointWithMetadata(contactPoint); }; + const options = contactPoints.map((receiver) => { + const integrations = receiver?.grafana_managed_receiver_configs; + const description = ; + + return { label: receiver.name, value: receiver, description }; + }); + if (errorInContactPointStatus) { return ; } @@ -64,21 +58,10 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo -
- - -
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && ( @@ -98,14 +81,6 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo ); } -function LinkToContactPoints() { - const hrefToContactPoints = '/alerting/notifications'; - return ( - - View or create contact points - - ); -} const getStyles = (theme: GrafanaTheme2) => ({ firstAlertManagerLine: css({ @@ -141,30 +116,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: `${theme.spacing(1)} ${theme.spacing(2)}`, marginTop: theme.spacing(2), }), - contactPointsInfo: css({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing(1), - marginTop: theme.spacing(1), - }), - refreshButton: css({ - color: theme.colors.text.secondary, - cursor: 'pointer', - borderRadius: theme.shape.radius.circle, - overflow: 'hidden', - }), - loading: css({ - pointerEvents: 'none', - animation: 'rotation 2s infinite linear', - '@keyframes rotation': { - from: { - transform: 'rotate(720deg)', - }, - to: { - transform: 'rotate(0deg)', - }, - }, - }), }); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index d78a2087c4c..5788b9dd44a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -1,75 +1,186 @@ -import { css } from '@emotion/css'; -import React from 'react'; +import { css, cx } from '@emotion/css'; +import React, { useCallback, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { ActionMeta, Field, FieldValidationMessage, InputControl, Select, Stack, useStyles2 } from '@grafana/ui'; +import { + ActionMeta, + Field, + FieldValidationMessage, + IconButton, + InputControl, + Select, + Stack, + TextLink, + useStyles2, +} from '@grafana/ui'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; +import { createUrl } from 'app/features/alerting/unified/utils/url'; -import { ContactPointReceiverSummary } from '../../../../contact-points/ContactPoints'; import { ContactPointWithMetadata } from '../../../../contact-points/utils'; export interface ContactPointSelectorProps { alertManager: string; - contactPoints: ContactPointWithMetadata[]; + options: Array<{ + label: string; + value: ContactPointWithMetadata; + description: React.JSX.Element; + }>; onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void; + refetchReceivers: () => Promise; } -export function ContactPointSelector({ alertManager, contactPoints, onSelectContactPoint }: ContactPointSelectorProps) { + +export function ContactPointSelector({ + alertManager, + options, + onSelectContactPoint, + refetchReceivers, +}: ContactPointSelectorProps) { const styles = useStyles2(getStyles); - const { control, watch } = useFormContext(); + const { control, watch, trigger } = useFormContext(); - const options = contactPoints.map((receiver) => { - const integrations = receiver?.grafana_managed_receiver_configs; - const description = ; + const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`); - return { label: receiver.name, value: receiver, description }; - }); - - const selectedContactPointWithMetadata = options.find( - (option) => option.value.name === watch(`contactPoints.${alertManager}.selectedContactPoint`) - )?.value; + const selectedContactPointWithMetadata = options.find((option) => option.value.name === contactPointInForm)?.value; const selectedContactPointSelectableValue = selectedContactPointWithMetadata ? { value: selectedContactPointWithMetadata, label: selectedContactPointWithMetadata.name } : undefined; + const LOADING_SPINNER_DURATION = 1000; + + const [loadingContactPoints, setLoadingContactPoints] = useState(false); + // we need to keep track if the fetching takes more than 1 second, so we can show the loading spinner until the fetching is done + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // if we have a contact point selected, check if it still exists in the event that someone has deleted it + const validateContactPoint = useCallback(() => { + if (contactPointInForm) { + trigger(`contactPoints.${alertManager}.selectedContactPoint`, { shouldFocus: true }); + } + }, [alertManager, contactPointInForm, trigger]); + + const onClickRefresh = () => { + setLoadingContactPoints(true); + Promise.all([refetchReceivers(), sleep(LOADING_SPINNER_DURATION)]).finally(() => { + setLoadingContactPoints(false); + validateContactPoint(); + }); + }; + + // validate the contact point and check if it still exists when mounting the component + useEffect(() => { + validateContactPoint(); + }, [validateContactPoint]); + return ( - - ( - <> -
- , _: ActionMeta) => { + onChange(value?.value?.name); + onSelectContactPoint(value?.value); + }} + // We are passing a JSX.Element into the "description" for options, which isn't how the TS typings are defined. + // The regular Select component will render it just fine, but we can't update the typings because SelectableValue + // is shared with other components where the "description" _has_ to be a string. + // I've tried unsuccessfully to separate the typings just I'm giving up :'( + // @ts-ignore + options={options} + width={50} + /> +
+ + +
+
+ + {/* Error can come from the required validation we have in here, or from the manual setError we do in the parent component. + The only way I found to check the custom error is to check if the field has a value and if it's not in the options. */} + + {error && {error?.message}} + + )} + rules={{ + required: { + value: true, + message: 'Contact point is required.', + }, + validate: { + contactPointExists: (value: string) => { + if (options.some((option) => option.value.name === value)) { + return true; + } + return `Contact point ${contactPointInForm} does not exist.`; + }, + }, + }} + control={control} + name={`contactPoints.${alertManager}.selectedContactPoint`} + /> +
+
); } +function LinkToContactPoints() { + const hrefToContactPoints = '/alerting/notifications'; + return ( + + View or create contact points + + ); +} const getStyles = (theme: GrafanaTheme2) => ({ contactPointsSelector: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing(1), marginTop: theme.spacing(1), }), + contactPointsInfo: css({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(1), + }), + refreshButton: css({ + color: theme.colors.text.secondary, + cursor: 'pointer', + borderRadius: theme.shape.radius.circle, + overflow: 'hidden', + }), + loading: css({ + pointerEvents: 'none', + animation: 'rotation 2s infinite linear', + '@keyframes rotation': { + from: { + transform: 'rotate(720deg)', + }, + to: { + transform: 'rotate(0deg)', + }, + }, + }), + warn: css({ + color: theme.colors.warning.text, + }), });