Alerting: Use Alerting package for contact point selection (#104772)

This commit is contained in:
Gilles De Mey
2025-06-24 11:08:27 +02:00
committed by GitHub
parent e1af2e15cb
commit d3b86b2bd5
13 changed files with 307 additions and 265 deletions
@@ -0,0 +1,31 @@
import { ComponentProps } from 'react';
import { Combobox } from '@grafana/ui';
interface ClearableProps<T> {
isClearable: true;
onChange: (option: T | null) => void;
}
interface NotClearableProps<T> {
isClearable?: false;
onChange: (option: T) => void;
}
type ComboboxClearableProps<T> = NotClearableProps<T> | ClearableProps<T>;
type AutoSizeConditionals =
| {
width: 'auto';
minWidth: number;
maxWidth?: number;
}
| {
width?: number;
minWidth?: never;
maxWidth?: never;
};
export type CustomComboBoxProps<T> = Omit<ComponentProps<typeof Combobox<string>>, 'options' | 'loading' | 'onChange'> &
ComboboxClearableProps<T> &
AutoSizeConditionals;
@@ -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<ContactPoint>;
/**
* 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<ComboboxOption>((item) => item.option);
const handleChange = ({ value }: ComboboxOption<string>) => {
const selectedItem = contactPointOptions.find(({ option }) => option.value === value);
if (!selectedItem) {
const handleChange = (selectedOption: ComboboxOption<string> | 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 <Combobox loading={isLoading} onChange={handleChange} options={options} />;
return <Combobox {...props} loading={isLoading} options={options} onChange={handleChange} />;
}
export { ContactPointSelector };
@@ -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();
@@ -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<ContactPointWithMetadata>;
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<SelectableValue<ContactPointWithMetadata>> = 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 (
<Stack>
<Select
virtualized={options.length > MAX_CONTACT_POINTS_RENDERED}
options={options}
value={matchedContactPoint}
{...selectProps}
isLoading={isLoading}
disabled={isLoading}
/>
{showRefreshButton && (
<IconButton
name="sync"
onClick={onClickRefresh}
aria-label={t('alerting.contact-point-selector.aria-label-refresh-contact-points', 'Refresh contact points')}
tooltip={t(
'alerting.contact-point-selector.tooltip-refresh-contact-points-list',
'Refresh contact points list'
)}
className={cx(styles.refreshButton, {
[styles.loading]: loaderSpinning || isLoading,
})}
/>
)}
</Stack>
<Select
virtualized={options.length > 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`,
},
}),
});
@@ -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
>
<div className={styles.container} data-testid="am-receiver-select">
<Controller
render={({ field: { onChange, ref, value, ...field } }) => (
<ContactPointSelector
selectProps={{
...field,
onChange: (changeValue) => handleContactPointSelect(changeValue, onChange),
}}
selectedContactPointName={value}
/>
)}
render={({ field: { onChange, ref, value, ...field } }) =>
isGrafanaAlertmanager ? (
<GrafanaManagedContactPointSelector
onChange={(contactPoint) => {
handleContactPointSelect(contactPoint?.spec.title, onChange);
}}
isClearable={false}
value={value}
placeholder={t(
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
'Choose a contact point'
)}
/>
) : (
<ExternalAlertmanagerContactPointSelector
selectProps={{
...field,
onChange: (changeValue) => handleContactPointSelect(changeValue.value?.name, onChange),
}}
selectedContactPointName={value}
/>
)
}
control={control}
name="receiver"
rules={{
@@ -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
<Field label={t('alerting.am-routes-expanded-form.label-contact-point', 'Contact point')}>
<Controller
render={({ field: { onChange, ref, value, ...field } }) => (
<ContactPointSelector
selectProps={{
...field,
className: formStyles.input,
onChange: (value) => handleContactPointSelect(value, onChange),
isClearable: true,
}}
selectedContactPointName={value}
/>
)}
render={({ field: { onChange, ref, value, ...field } }) =>
isGrafanaAlertmanager ? (
<GrafanaManagedContactPointSelector
onChange={(contactPoint) => {
handleContactPointSelect(contactPoint?.spec.title, onChange);
}}
isClearable
value={value}
placeholder={t(
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
'Choose a contact point'
)}
/>
) : (
<ExternalAlertmanagerContactPointSelector
selectProps={{
...field,
className: formStyles.input,
onChange: (value) => handleContactPointSelect(value.value?.name, onChange),
isClearable: true,
}}
selectedContactPointName={value}
/>
)
}
control={control}
name="receiver"
/>
@@ -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<HTMLInputElement | null>(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 }}
>
<ContactPointSelector
selectProps={{
id: 'receiver',
'aria-label': 'Search by contact point',
onChange: (option) => {
setSearchParams({ contactPoint: option?.value?.name });
},
width: 28,
isClearable: true,
}}
selectedContactPointName={searchParams.get('contactPoint') ?? undefined}
/>
{isGrafanaAlertmanager ? (
<GrafanaManagedContactPointSelector
placeholder={t(
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
'Choose a contact point'
)}
id="receiver"
onChange={(contactPoint) => {
// 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}
/>
) : (
<ExternalAlertmanagerContactPointSelector
selectProps={{
id: 'receiver',
'aria-label': 'Search by contact point',
onChange: (option) => {
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}
/>
)}
</Field>
)}
{hasFilters && (
@@ -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<ContactPointWithMetadata>,
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);
};
@@ -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<RuleFormValues>();
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
<div className={styles.secondAlertManagerLine} />
</Stack>
<Stack direction="row" gap={1} alignItems="center">
<ContactPointSelector alertManager={alertManagerName} onSelectContactPoint={onSelectContactPoint} />
<ContactPointSelector alertManager={alertManagerName} />
</Stack>
{selectedContactPointWithMetadata?.grafana_managed_receiver_configs && (
<ContactPointDetails receivers={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
*/}
<div className={styles.routingSection}>
<CollapsableSection
label={t(
@@ -51,13 +51,29 @@ const selectFolderAndGroup = async (user: UserEvent) => {
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
@@ -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<RuleFormValues>();
export function ContactPointSelector({ alertManager }: ContactPointSelectorProps) {
const { control, watch, trigger } = useFormContext<RuleFormValues>();
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 (
<Stack direction="column">
<Stack direction="row" alignItems="center">
<Field
label={t('alerting.contact-point-selector.contact-point-picker-label-contact-point', 'Contact point')}
data-testid="contact-point-picker"
>
<Controller
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<Stack>
<ContactPointSelectorDropdown
selectProps={{
onChange: (value: SelectableValue<ContactPointWithMetadata>, _: ActionMeta) => {
onChange(value?.value?.name);
onSelectContactPoint(value?.value);
},
width: 50,
}}
showRefreshButton
selectedContactPointName={contactPointInForm}
onError={handleError}
/>
<LinkToContactPoints />
</Stack>
<Stack direction="row" alignItems="center">
<Field
label={t('alerting.contact-point-selector.contact-point-picker-label-contact-point', 'Contact point')}
data-testid="contact-point-picker"
>
<Controller
name={selectedContactPointField}
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<Stack>
<GrafanaManagedContactPointSelector
isClearable={false}
onChange={(contactPoint) => onChange(contactPoint.spec.title)}
width={50}
value={contactPointInForm}
/>
<LinkToContactPoints />
</Stack>
{/* 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 && <FieldValidationMessage>{error?.message}</FieldValidationMessage>}
</>
)}
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`}
/>
</Field>
</Stack>
{error && <FieldValidationMessage>{error?.message}</FieldValidationMessage>}
</>
)}
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}
/>
</Field>
</Stack>
);
}
@@ -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 }:
/>
</div>
{canRenderContactPointSelector && (
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
<Stack direction="column" gap={0}>
<Field
label={
<Label htmlFor="contactPointFilter">
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
</Label>
}
>
<ContactPointSelector
selectedContactPointName={filterState.contactPoint}
selectProps={{
inputId: 'contactPointFilter',
width: 40,
onChange: (selectValue) => {
handleContactPointChange(selectValue?.value?.name!);
},
isClearable: true,
}}
/>
</Field>
</Stack>
</AlertmanagerProvider>
<Stack direction="column" gap={0}>
<Field
label={
<Label htmlFor="contactPointFilter">
<Trans i18nKey="alerting.contactPointFilter.label">Contact point</Trans>
</Label>
}
>
<ContactPointSelector
id="contactPointFilter"
value={filterState.contactPoint ?? null}
width={40}
placeholder={t(
'alerting.notification-policies-filter.placeholder-search-by-contact-point',
'Choose a contact point'
)}
isClearable
onChange={(contactPoint) => {
handleContactPointChange(contactPoint?.spec.title ?? '');
}}
/>
</Field>
</Stack>
)}
{pluginsFilterEnabled && (
<div>
+7 -3
View File
@@ -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": {