diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ComboBox.types.ts b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ComboBox.types.ts new file mode 100644 index 00000000000..b96ddd209c0 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ComboBox.types.ts @@ -0,0 +1,31 @@ +import { ComponentProps } from 'react'; + +import { Combobox } from '@grafana/ui'; + +interface ClearableProps { + isClearable: true; + onChange: (option: T | null) => void; +} + +interface NotClearableProps { + isClearable?: false; + onChange: (option: T) => void; +} + +type ComboboxClearableProps = NotClearableProps | ClearableProps; + +type AutoSizeConditionals = + | { + width: 'auto'; + minWidth: number; + maxWidth?: number; + } + | { + width?: number; + minWidth?: never; + maxWidth?: never; + }; + +export type CustomComboBoxProps = Omit>, 'options' | 'loading' | 'onChange'> & + ComboboxClearableProps & + AutoSizeConditionals; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx index 07265fb36e9..3107319a9ea 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx @@ -6,17 +6,17 @@ import type { ContactPoint } from '../../../api/v0alpha1/types'; import { useListContactPointsv0alpha1 } from '../../hooks/useContactPoints'; import { getContactPointDescription } from '../../utils'; +import { CustomComboBoxProps } from './ComboBox.types'; + const collator = new Intl.Collator('en', { sensitivity: 'accent' }); -type ContactPointSelectorProps = { - onChange: (contactPoint: ContactPoint) => void; -}; +export type ContactPointSelectorProps = CustomComboBoxProps; /** * Contact Point Combobox which lists all available contact points * @TODO make ComboBox accept a ReactNode so we can use icons and such */ -function ContactPointSelector({ onChange }: ContactPointSelectorProps) { +function ContactPointSelector(props: ContactPointSelectorProps) { const { currentData: contactPoints, isLoading } = useListContactPointsv0alpha1(); // Create a mapping of options with their corresponding contact points @@ -35,16 +35,23 @@ function ContactPointSelector({ onChange }: ContactPointSelectorProps) { const options = contactPointOptions.map((item) => item.option); - const handleChange = ({ value }: ComboboxOption) => { - const selectedItem = contactPointOptions.find(({ option }) => option.value === value); - if (!selectedItem) { + const handleChange = (selectedOption: ComboboxOption | null) => { + if (selectedOption == null && props.isClearable) { + props.onChange(null); return; } - onChange(selectedItem.contactPoint); + if (selectedOption) { + const matchedOption = contactPointOptions.find(({ option }) => option.value === selectedOption.value); + if (!matchedOption) { + return; + } + + props.onChange(matchedOption.contactPoint); + } }; - return ; + return ; } export { ContactPointSelector }; diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx index 00be5c8167b..f48f3eb16fe 100644 --- a/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx +++ b/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx @@ -140,6 +140,22 @@ const getRootRoute = async () => { }; describe('NotificationPolicies', () => { + // combobox hack :/ + beforeAll(() => { + const mockGetBoundingClientRect = jest.fn(() => ({ + width: 120, + height: 120, + top: 0, + left: 0, + bottom: 0, + right: 0, + })); + + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); + }); + beforeEach(() => { setupDataSources(...Object.values(dataSources)); grantUserPermissions([ @@ -202,11 +218,9 @@ describe('NotificationPolicies', () => { await openDefaultPolicyEditModal(); - // configure receiver & group by - const receiverSelect = await ui.receiverSelect.find(); - // The contact points are fetched from the k8s API, which we aren't overriding here // when we use a different + const receiverSelect = ui.receiverSelect.get(); await clickSelectOption(receiverSelect, 'lotsa-emails'); const groupSelect = ui.groupSelect.get(); diff --git a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx index e09be0657a7..2886b04570e 100644 --- a/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/ContactPointSelector.tsx @@ -1,9 +1,8 @@ -import { css, cx, keyframes } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Alert, IconButton, Select, SelectCommonProps, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, Select, SelectCommonProps, Text } from '@grafana/ui'; import { ContactPointReceiverSummary } from 'app/features/alerting/unified/components/contact-points/ContactPoint'; import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; @@ -12,30 +11,22 @@ import { ContactPointWithMetadata } from '../contact-points/utils'; const MAX_CONTACT_POINTS_RENDERED = 500; -// Mock sleep method, as fetching receivers is very fast and may seem like it hasn't occurred -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const LOADING_SPINNER_DURATION = 1000; - type ContactPointSelectorProps = { selectProps: SelectCommonProps; - showRefreshButton?: boolean; /** Name of a contact point to optionally find and set as the preset value on the dropdown */ selectedContactPointName?: string | null; onError?: (error: Error) => void; }; -export const ContactPointSelector = ({ +export const ExternalAlertmanagerContactPointSelector = ({ selectProps, - showRefreshButton, selectedContactPointName, onError = () => {}, }: ContactPointSelectorProps) => { const { selectedAlertmanager } = useAlertmanager(); - const { contactPoints, isLoading, error, refetch } = useContactPointsWithStatus({ + const { contactPoints, isLoading, error } = useContactPointsWithStatus({ alertmanager: selectedAlertmanager!, }); - const [loaderSpinning, setLoaderSpinning] = useState(false); - const styles = useStyles2(getStyles); const options: Array> = contactPoints.map((contactPoint) => { return { @@ -53,14 +44,6 @@ export const ContactPointSelector = ({ return options.find((option) => option.value?.name === selectedContactPointName) || null; }, [options, selectedContactPointName]); - // force some minimum wait period for fetching contact points - const onClickRefresh = () => { - setLoaderSpinning(true); - Promise.all([refetch(), sleep(LOADING_SPINNER_DURATION)]).finally(() => { - setLoaderSpinning(false); - }); - }; - useEffect(() => { // If the contact points are fetched successfully and the selected contact point is not in the list, show an error if (!isLoading && selectedContactPointName && !matchedContactPoint) { @@ -82,56 +65,13 @@ export const ContactPointSelector = ({ } return ( - - MAX_CONTACT_POINTS_RENDERED} + options={options} + value={matchedContactPoint} + {...selectProps} + isLoading={isLoading} + disabled={isLoading} + /> ); }; - -const rotation = keyframes({ - from: { - transform: 'rotate(0deg)', - }, - to: { - transform: 'rotate(720deg)', - }, -}); - -const getStyles = (theme: GrafanaTheme2) => ({ - refreshButton: css({ - color: theme.colors.text.secondary, - cursor: 'pointer', - borderRadius: theme.shape.radius.circle, - overflow: 'hidden', - }), - loading: css({ - pointerEvents: 'none', - [theme.transitions.handleMotion('no-preference')]: { - animation: `${rotation} 2s infinite linear`, - }, - [theme.transitions.handleMotion('reduce')]: { - animation: `${rotation} 6s infinite linear`, - }, - }), -}); diff --git a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx index b5517caa3f8..dc87060c03d 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx @@ -1,12 +1,14 @@ import { ReactNode, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable'; import { Trans, t } from '@grafana/i18n'; import { Collapse, Field, Link, MultiSelect, useStyles2 } from '@grafana/ui'; -import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; +import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils'; import { RouteWithID } from 'app/plugins/datasource/alertmanager/types'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { FormAmRoute } from '../../types/amroutes'; import { amRouteToFormAmRoute, @@ -33,6 +35,7 @@ export interface AmRootRouteFormProps { export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmit, route }: AmRootRouteFormProps) => { const styles = useStyles2(getFormStyles); const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false); + const { isGrafanaAlertmanager } = useAlertmanager(); const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route.group_by)); const defaultValues = amRouteToFormAmRoute(route); @@ -59,15 +62,29 @@ export const AmRootRouteForm = ({ actionButtons, alertManagerSourceName, onSubmi >
( - handleContactPointSelect(changeValue, onChange), - }} - selectedContactPointName={value} - /> - )} + render={({ field: { onChange, ref, value, ...field } }) => + isGrafanaAlertmanager ? ( + { + handleContactPointSelect(contactPoint?.spec.title, onChange); + }} + isClearable={false} + value={value} + placeholder={t( + 'alerting.notification-policies-filter.placeholder-search-by-contact-point', + 'Choose a contact point' + )} + /> + ) : ( + handleContactPointSelect(changeValue.value?.name, onChange), + }} + selectedContactPointName={value} + /> + ) + } control={control} name="receiver" rules={{ diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx index 3062ac31145..14ed5c44d52 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { ReactNode, useState } from 'react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; +import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { @@ -18,7 +19,7 @@ import { useStyles2, } from '@grafana/ui'; import MuteTimingsSelector from 'app/features/alerting/unified/components/alertmanager-entities/MuteTimingsSelector'; -import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; +import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils'; import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities'; import { MatcherOperator, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; @@ -51,7 +52,7 @@ export interface AmRoutesExpandedFormProps { export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults }: AmRoutesExpandedFormProps) => { const styles = useStyles2(getStyles); const formStyles = useStyles2(getFormStyles); - const { selectedAlertmanager } = useAlertmanager(); + const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager(); const [, canSeeMuteTimings] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval); const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route?.group_by)); @@ -177,17 +178,31 @@ export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults ( - handleContactPointSelect(value, onChange), - isClearable: true, - }} - selectedContactPointName={value} - /> - )} + render={({ field: { onChange, ref, value, ...field } }) => + isGrafanaAlertmanager ? ( + { + handleContactPointSelect(contactPoint?.spec.title, onChange); + }} + isClearable + value={value} + placeholder={t( + 'alerting.notification-policies-filter.placeholder-search-by-contact-point', + 'Choose a contact point' + )} + /> + ) : ( + handleContactPointSelect(value.value?.name, onChange), + isClearable: true, + }} + selectedContactPointName={value} + /> + ) + } control={control} name="receiver" /> diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx index 7fc8134b769..b6466ab6433 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx @@ -2,13 +2,14 @@ import { css } from '@emotion/css'; import { debounce, isEqual } from 'lodash'; import { useCallback, useEffect, useRef } from 'react'; +import { ContactPointSelector as GrafanaManagedContactPointSelector } from '@grafana/alerting/unstable'; import { Trans, t } from '@grafana/i18n'; import { Button, Field, Icon, Input, Label, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui'; -import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities'; import { ObjectMatcher, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; +import { useAlertmanager } from '../../state/AlertmanagerContext'; import { matcherToObjectMatcher } from '../../utils/alertmanager'; import { normalizeMatchers, @@ -17,6 +18,8 @@ import { unquoteIfRequired, } from '../../utils/matchers'; +import { ExternalAlertmanagerContactPointSelector } from './ContactPointSelector'; + interface NotificationPoliciesFilterProps { onChangeMatchers: (labels: ObjectMatcher[]) => void; onChangeReceiver: (receiver: string | undefined) => void; @@ -29,6 +32,7 @@ const NotificationPoliciesFilter = ({ matchingCount, }: NotificationPoliciesFilterProps) => { const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint); + const { isGrafanaAlertmanager } = useAlertmanager(); const [searchParams, setSearchParams] = useURLSearchParams(); const searchInputRef = useRef(null); const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams); @@ -106,18 +110,43 @@ const NotificationPoliciesFilter = ({ label={t('alerting.notification-policies-filter.label-search-by-contact-point', 'Search by contact point')} style={{ marginBottom: 0 }} > - { - setSearchParams({ contactPoint: option?.value?.name }); - }, - width: 28, - isClearable: true, - }} - selectedContactPointName={searchParams.get('contactPoint') ?? undefined} - /> + {isGrafanaAlertmanager ? ( + { + // clearing the contact point will return "null" + if (!contactPoint) { + setSearchParams({ contactPoint: undefined }); + } else { + setSearchParams({ contactPoint: contactPoint.spec.title }); + } + }} + width={28} + isClearable + value={searchParams.get('contactPoint') ?? undefined} + /> + ) : ( + { + setSearchParams({ contactPoint: option?.value?.name }); + }, + width: 28, + isClearable: true, + placeholder: t( + 'alerting.notification-policies-filter.placeholder-search-by-contact-point', + 'Choose a contact point' + ), + }} + selectedContactPointName={searchParams.get('contactPoint') ?? undefined} + /> + )} )} {hasFilters && ( diff --git a/public/app/features/alerting/unified/components/notification-policies/utils.ts b/public/app/features/alerting/unified/components/notification-policies/utils.ts index f089cef800f..55876552dbf 100644 --- a/public/app/features/alerting/unified/components/notification-policies/utils.ts +++ b/public/app/features/alerting/unified/components/notification-policies/utils.ts @@ -1,19 +1,16 @@ import { ControllerRenderProps } from 'react-hook-form'; -import { SelectableValue } from '@grafana/data'; -import { ContactPointWithMetadata } from 'app/features/alerting/unified/components/contact-points/utils'; - export const handleContactPointSelect = ( - value: SelectableValue, + name: string | undefined | null, onChange: ControllerRenderProps['onChange'] ) => { - if (value === null) { + if (name === null) { return onChange(null); } - if (!value) { + if (!name) { return onChange(''); } - return onChange(value.value?.name); + return onChange(name); }; 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 74bd67b2519..e7f1afae424 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,5 +1,4 @@ import { css } from '@emotion/css'; -import { useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; @@ -8,11 +7,8 @@ import { CollapsableSection, Stack, Text, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { AlertManagerDataSource } from 'app/features/alerting/unified/utils/datasource'; -import { useContactPointsWithStatus } from '../../../contact-points/useContactPoints'; -import { ContactPointWithMetadata } from '../../../contact-points/utils'; import { NeedHelpInfo } from '../../NeedHelpInfo'; -import { ContactPointDetails } from './contactPoint/ContactPointDetails'; import { ContactPointSelector } from './contactPoint/ContactPointSelector'; import { ActiveTimingFields } from './route-settings/ActiveTimingFields'; import { MuteTimingFields } from './route-settings/MuteTimingFields'; @@ -27,29 +23,8 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo const alertManagerName = alertManager.name; - const [selectedContactPointWithMetadata, setSelectedContactPointWithMetadata] = useState< - ContactPointWithMetadata | undefined - >(); const { watch } = useFormContext(); - const contactPointInForm = watch(`contactPoints.${alertManagerName}.selectedContactPoint`); - const { contactPoints } = useContactPointsWithStatus({ - // we only fetch the contact points with metadata for the first time we render an existing alert rule - alertmanager: alertManagerName, - skip: Boolean(selectedContactPointWithMetadata), - }); - const contactPointWithMetadata = contactPoints.find((cp) => cp.name === contactPointInForm); - - useEffect(() => { - if (contactPointWithMetadata && !selectedContactPointWithMetadata) { - onSelectContactPoint(contactPointWithMetadata); - } - }, [contactPointWithMetadata, selectedContactPointWithMetadata]); - - const onSelectContactPoint = (contactPoint?: ContactPointWithMetadata) => { - setSelectedContactPointWithMetadata(contactPoint); - }; - const hasRouteSettings = watch(`contactPoints.${alertManagerName}.overrideGrouping`) || watch(`contactPoints.${alertManagerName}.overrideTimings`) || @@ -67,11 +42,12 @@ export function AlertManagerManualRouting({ alertManager }: AlertManagerManualRo
- + - {selectedContactPointWithMetadata?.grafana_managed_receiver_configs && ( - - )} + {/* @TODO + we can show the contact point details here when it's selected but we currently don't have a + way to summarize the details from the ContactPoint type in @grafana/alerting + */}
{ await clickSelectOption(groupInput, grafanaRulerGroup.name); }; -const selectContactPoint = async (user: UserEvent, contactPointName: string) => { +const selectContactPoint = async (contactPointName: string) => { const contactPointInput = await ui.inputs.simplifiedRouting.contactPoint.find(); - await user.click(byRole('combobox').get(contactPointInput)); await clickSelectOption(contactPointInput, contactPointName); }; +// combobox hack +beforeEach(() => { + const mockGetBoundingClientRect = jest.fn(() => ({ + width: 120, + height: 120, + top: 0, + left: 0, + bottom: 0, + right: 0, + })); + + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); +}); + setupMswServer(); + describe('Can create a new grafana managed alert using simplified routing', () => { beforeEach(() => { window.localStorage.clear(); @@ -124,7 +140,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = //select contact point routing await user.click(ui.inputs.simplifiedRouting.contactPointRouting.get()); - await selectContactPoint(user, contactPointName); + await selectContactPoint(contactPointName); // save and check what was sent to backend await user.click(ui.buttons.save.get()); @@ -139,9 +155,8 @@ describe('Can create a new grafana managed alert using simplified routing', () = await user.click(await ui.inputs.simplifiedRouting.contactPointRouting.find()); - await selectContactPoint(user, 'Email'); - - expect(await screen.findByText('Email')).toBeInTheDocument(); + await selectContactPoint('lotsa-emails'); + expect(screen.getByDisplayValue('lotsa-emails')).toBeInTheDocument(); }); describe('switch modes enabled', () => { @@ -157,7 +172,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await selectFolderAndGroup(user); - await selectContactPoint(user, contactPointName); + await selectContactPoint(contactPointName); // save and check what was sent to backend await user.click(ui.buttons.save.get()); @@ -211,7 +226,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await user.type(await ui.inputs.name.find(), 'my great new rule'); await selectFolderAndGroup(user); - await selectContactPoint(user, contactPointName); + await selectContactPoint(contactPointName); await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode 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 e98c89fe695..a2e88321e4f 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,93 +1,92 @@ -import { useCallback, useEffect } from 'react'; +import { QueryStatus } from '@reduxjs/toolkit/query'; +import { isEmpty } from 'lodash'; +import { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { SelectableValue } from '@grafana/data'; +import { + ContactPointSelector as GrafanaManagedContactPointSelector, + alertingAPIv0alpha1, +} from '@grafana/alerting/unstable'; import { Trans, t } from '@grafana/i18n'; -import { ActionMeta, Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui'; -import { ContactPointSelector as ContactPointSelectorDropdown } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; +import { Field, FieldValidationMessage, Stack, TextLink } from '@grafana/ui'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import { createRelativeUrl } from 'app/features/alerting/unified/utils/url'; -import { ContactPointWithMetadata } from '../../../../contact-points/utils'; - export interface ContactPointSelectorProps { alertManager: string; - onSelectContactPoint: (contactPoint?: ContactPointWithMetadata) => void; } -export function ContactPointSelector({ alertManager, onSelectContactPoint }: ContactPointSelectorProps) { - const { control, watch, trigger, setError } = useFormContext(); +export function ContactPointSelector({ alertManager }: ContactPointSelectorProps) { + const { control, watch, trigger } = useFormContext(); - const contactPointInForm = watch(`contactPoints.${alertManager}.selectedContactPoint`); + const selectedContactPointField = `contactPoints.${alertManager}.selectedContactPoint` as const; + const contactPointInForm = watch(selectedContactPointField); - // Wrap in useCallback to avoid infinite render loop - const handleError = useCallback( - (err: Error) => { - setError(`contactPoints.${alertManager}.selectedContactPoint`, { - message: err.message, - }); - }, - [alertManager, setError] - ); + // check if the contact point still exists, we'll use listReceiver to check if the contact point exists because getReceiver doesn't work with + // contact point titles but with UUIDs (which is not what we store on the alert rule definition) + const { currentData, status } = alertingAPIv0alpha1.endpoints.listReceiver.useQuery({ + fieldSelector: `spec.title=${contactPointInForm}`, + }); - // 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 contactPointNotFound = contactPointInForm && status === QueryStatus.fulfilled && isEmpty(currentData?.items); - // validate the contact point and check if it still exists when mounting the component + // validate the contact point and check if it still exists when we've gotten a response from the API useEffect(() => { - validateContactPoint(); - }, [validateContactPoint]); + if (contactPointInForm && status === QueryStatus.fulfilled) { + trigger(selectedContactPointField, { shouldFocus: true }); + } + }, [contactPointInForm, selectedContactPointField, status, trigger]); return ( - - - - ( - <> - - , _: ActionMeta) => { - onChange(value?.value?.name); - onSelectContactPoint(value?.value); - }, - width: 50, - }} - showRefreshButton - selectedContactPointName={contactPointInForm} - onError={handleError} - /> - - + + + ( + <> + + onChange(contactPoint.spec.title)} + width={50} + value={contactPointInForm} + /> + + - {/* 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 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: t( - 'alerting.contact-point-selector.message.contact-point-is-required', - 'Contact point is required.' - ), - }, - }} - control={control} - name={`contactPoints.${alertManager}.selectedContactPoint`} - /> - - + {error && {error?.message}} + + )} + rules={{ + validate: () => { + if (contactPointNotFound) { + return t( + 'alerting.contactPoints.validation.notFound', + `Contact point "{{contactPoint}}" could not be found`, + { + contactPoint: contactPointInForm, + } + ); + } + return true; + }, + required: { + value: true, + message: t( + 'alerting.contact-point-selector.message.contact-point-is-required', + 'Contact point is required.' + ), + }, + }} + control={control} + /> + ); } diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index f80198f1a77..61dc5f119e7 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -2,12 +2,12 @@ import { css } from '@emotion/css'; import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { ContactPointSelector } from '@grafana/alerting/unstable'; import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { contextSrv } from 'app/core/core'; -import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; import { AccessControlAction } from 'app/types'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; @@ -20,8 +20,6 @@ import { import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; import { RuleHealth } from '../../../search/rulesSearchParser'; -import { AlertmanagerProvider } from '../../../state/AlertmanagerContext'; -import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; @@ -231,29 +229,29 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: />
{canRenderContactPointSelector && ( - - - - Contact point - - } - > - { - handleContactPointChange(selectValue?.value?.name!); - }, - isClearable: true, - }} - /> - - - + + + Contact point + + } + > + { + handleContactPointChange(contactPoint?.spec.title ?? ''); + }} + /> + + )} {pluginsFilterEnabled && (
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d3a8c831fd1..3bc6c47ec73 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -846,13 +846,11 @@ "tooltip-provisioned-contact-points": "Provisioned contact points cannot be edited in the UI" }, "contact-point-selector": { - "aria-label-refresh-contact-points": "Refresh contact points", "contact-point-picker-label-contact-point": "Contact point", "message": { "contact-point-is-required": "Contact point is required." }, - "title-failed-to-fetch-contact-points": "Failed to fetch contact points", - "tooltip-refresh-contact-points-list": "Refresh contact points list" + "title-failed-to-fetch-contact-points": "Failed to fetch contact points" }, "contact-points": { "create": "Create contact point", @@ -912,6 +910,11 @@ "contactPointFilter": { "label": "Contact point" }, + "contactPoints": { + "validation": { + "notFound": "Contact point \"{{contactPoint}}\" could not be found" + } + }, "continue-matching-indicator": { "content-route-continue-matching-other-policies": "This route will continue matching other policies" }, @@ -1908,6 +1911,7 @@ }, "notification-policies-filter": { "label-search-by-contact-point": "Search by contact point", + "placeholder-search-by-contact-point": "Choose a contact point", "search-query-input-placeholder-search": "Search" }, "notification-policies-list": {