Alerting: Fix template editing issues for contact points (#95387)

pull/95429/head
Tom Ratcliffe 9 months ago committed by GitHub
parent b2de69d741
commit b0e116c5fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      public/app/features/alerting/unified/api/templateApi.ts
  2. 76
      public/app/features/alerting/unified/components/contact-points/EditContactPoint.test.tsx
  3. 4
      public/app/features/alerting/unified/components/contact-points/__snapshots__/useContactPoints.test.tsx.snap
  4. 10
      public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx
  5. 2
      public/app/features/alerting/unified/components/receivers/__snapshots__/NewReceiverView.test.tsx.snap
  6. 12
      public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx
  7. 109
      public/app/features/alerting/unified/components/receivers/form/fields/TemplateSelector.tsx
  8. 2
      public/app/features/alerting/unified/mockGrafanaNotifiers.ts
  9. 2
      public/app/features/alerting/unified/mocks/server/entities/alertmanager-config/grafana-alertmanager-config.ts
  10. 15
      public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts
  11. 1
      public/locales/en-US/grafana.json
  12. 1
      public/locales/pseudo-LOCALE/grafana.json

@ -41,9 +41,11 @@ generatedTemplatesApi.enhanceEndpoints({
},
});
export type TemplatesTestPayload = { template: string; alerts: AlertField[]; name: string };
export const templatesApi = generatedTemplatesApi.injectEndpoints({
endpoints: (build) => ({
previewTemplate: build.mutation<TemplatePreviewResponse, { template: string; alerts: AlertField[]; name: string }>({
previewTemplate: build.mutation<TemplatePreviewResponse, TemplatesTestPayload>({
query: ({ template, alerts, name }) => ({
url: previewTemplateUrl,
data: { template: template, alerts: alerts, name: name },

@ -0,0 +1,76 @@
import 'core-js/stable/structured-clone';
import { Routes, Route } from 'react-router-dom-v5-compat';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render, screen } from 'test/test-utils';
import EditContactPoint from 'app/features/alerting/unified/components/contact-points/EditContactPoint';
import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../../mockApi';
import { grantUserPermissions } from '../../mocks';
setupMswServer();
const Index = () => {
return <div>redirected</div>;
};
const renderEditContactPoint = (contactPointUid: string) =>
render(
<Routes>
<Route path="/alerting/notifications" element={<Index />} />
<Route path="/alerting/notifications/receivers/:name/edit" element={<EditContactPoint />} />
</Routes>,
{
historyOptions: { initialEntries: [`/alerting/notifications/receivers/${contactPointUid}/edit`] },
}
);
beforeEach(() => {
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
});
const getTemplatePreviewContent = async () =>
await screen.findByRole('presentation', { description: /Preview with the default payload/i });
const templatesSelectorTestId = 'existing-templates-selector';
describe('Edit contact point', () => {
it('can edit a contact point with existing template field values', async () => {
const { user } = renderEditContactPoint('lotsa-emails');
// Expand settings and open "edit message template" drawer
await user.click(await screen.findByText(/optional email settings/i));
await user.click(await screen.findByRole('button', { name: /edit message/i }));
expect(await screen.findByRole('dialog', { name: /edit message/i })).toBeInTheDocument();
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
// Change the preset template and check that the preview updates correctly
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'custom-email');
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for custom-email/i);
// Close the drawer
await user.click(screen.getByRole('button', { name: /^save$/i }));
// Check a setting that has an existing custom value, and change it to a preset template
await user.click(await screen.findByRole('button', { name: /edit subject/i }));
expect(await screen.findByRole('dialog', { name: /edit subject/i })).toBeInTheDocument();
// If this isn't correct, then we haven't set the correct initial state for the radio buttons/tabs
expect(await screen.findByLabelText(/custom template value/i)).toHaveValue('some custom value');
await user.click(screen.getByRole('radio', { name: /select existing template/i }));
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'slack-template');
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
// Close the drawer
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText(/template: custom-email/i)).toBeInTheDocument();
expect(await screen.findByText(/template: slack-template/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /save contact point/i }));
expect(await screen.findByText(/redirected/i)).toBeInTheDocument();
});
});

@ -49,7 +49,9 @@ exports[`useContactPoints should return contact points with status 1`] = `
"secureFields": {},
"settings": {
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
"message": "{{ template "slack-template" . }}",
"singleEmail": false,
"subject": "some custom value",
},
"type": "email",
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
@ -243,7 +245,9 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
"secureFields": {},
"settings": {
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
"message": "{{ template "slack-template" . }}",
"singleEmail": false,
"subject": "some custom value",
},
"type": "email",
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",

@ -4,12 +4,16 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, useStyles2 } from '@grafana/ui';
export function EditorColumnHeader({ label, actions }: { label: string; actions?: React.ReactNode }) {
type Props = { label: string; actions?: React.ReactNode; id?: string };
export function EditorColumnHeader({ label, actions, id }: Props) {
const styles = useStyles2(editorColumnStyles);
return (
<div className={styles.container}>
<Label className={styles.label}>{label}</Label>
<Label className={styles.label} id={id}>
{label}
</Label>
<Stack direction="row" gap={1}>
{actions}
</Stack>
@ -17,7 +21,7 @@ export function EditorColumnHeader({ label, actions }: { label: string; actions?
);
}
export const editorColumnStyles = (theme: GrafanaTheme2) => ({
const editorColumnStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'row',

@ -76,7 +76,9 @@ exports[`alerting API server disabled should be able to test and save a receiver
"secureFields": {},
"settings": {
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
"message": "{{ template "slack-template" . }}",
"singleEmail": false,
"subject": "some custom value",
},
"type": "email",
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",

@ -35,6 +35,8 @@ export function TemplateContentAndPreview({
const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
const previewToRender = getPreviewResults(error, payloadFormatError, data);
const templatePreviewId = 'template-preview';
return (
<div className={cx(className, styles.mainContainer)}>
<div className={styles.container}>
@ -58,9 +60,15 @@ export function TemplateContentAndPreview({
{isGrafanaAlertManager && (
<div className={styles.container}>
<EditorColumnHeader label="Preview with the default payload" />
<EditorColumnHeader id={templatePreviewId} label="Preview with the default payload" />
<Box flex={1}>
<div className={styles.viewerContainer({ height: 'minHeight' })}>{previewToRender}</div>
<div
role="presentation"
aria-describedby={templatePreviewId}
className={styles.viewerContainer({ height: 'minHeight' })}
>
{previewToRender}
</div>
</Box>
</div>
)}

@ -8,7 +8,7 @@ import {
Button,
Drawer,
IconButton,
Input,
Label,
RadioButtonGroup,
Select,
Stack,
@ -16,6 +16,7 @@ import {
TextArea,
useStyles2,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import {
trackEditInputWithTemplate,
trackUseCustomInputInTemplate,
@ -34,6 +35,8 @@ import { defaultPayloadString } from '../../TemplateForm';
import { TemplateContentAndPreview } from './TemplateContentAndPreview';
import { getTemplateName, getUseTemplateText, matchesOnlyOneTemplate, parseTemplates } from './utils';
const { useGetDefaultTemplatesQuery } = templatesApi;
interface TemplatesPickerProps {
onSelect: (temnplate: string) => void;
option: NotificationChannelOption;
@ -45,27 +48,23 @@ export function TemplatesPicker({ onSelect, option, valueInForm }: TemplatesPick
setShowTemplates(true);
trackEditInputWithTemplate();
};
const handleClose = () => setShowTemplates(false);
return (
<>
<Button
icon="edit"
tooltip={'Edit using existing templates.'}
tooltip={`Edit ${option.label.toLowerCase()} using existing templates.`}
onClick={onClick}
variant="secondary"
size="sm"
aria-label={'Select available template from the list of available templates.'}
>
{`Edit ${option.label}`}
</Button>
{showTemplates && (
<Drawer title={`Edit ${option.label}`} size="md" onClose={() => setShowTemplates(false)}>
<TemplateSelector
onSelect={onSelect}
onClose={() => setShowTemplates(false)}
option={option}
valueInForm={valueInForm}
/>
<Drawer title={`Edit ${option.label}`} size="md" onClose={handleClose}>
<TemplateSelector onSelect={onSelect} onClose={handleClose} option={option} valueInForm={valueInForm} />
</Drawer>
)}
</>
@ -102,10 +101,6 @@ export function getTemplateOptions(templateFiles: NotificationTemplate[], defaul
// return the sum of default and custom templates
return Array.from(templateMap.values());
}
function getContentFromOptions(name: string, options: Array<SelectableValue<Template>>) {
const template = options.find((option) => option.label === name);
return template?.value?.content ?? '';
}
export interface Template {
name: string;
@ -117,40 +112,44 @@ interface TemplateSelectorProps {
option: NotificationChannelOption;
valueInForm: string;
}
function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSelectorProps) {
const styles = useStyles2(getStyles);
const useGetDefaultTemplatesQuery = templatesApi.endpoints.getDefaultTemplates.useQuery;
const [template, setTemplate] = useState<Template | undefined>(undefined);
const [inputToUpdate, setInputToUpdate] = useState<string>('');
const [inputToUpdateCustom, setInputToUpdateCustom] = useState<string>(valueInForm);
const valueInFormIsCustom = Boolean(valueInForm) && !matchesOnlyOneTemplate(valueInForm);
const [template, setTemplate] = useState<SelectableValue<Template> | undefined>(undefined);
const [customTemplateValue, setCustomTemplateValue] = useState<string>(valueInForm);
const { selectedAlertmanager } = useAlertmanager();
const { data = [], error, isLoading } = useNotificationTemplates({ alertmanager: selectedAlertmanager! });
const { data: defaultTemplates } = useGetDefaultTemplatesQuery();
const [templateOption, setTemplateOption] = useState<TemplateFieldOption>('Existing');
const [templateOption, setTemplateOption] = useState<TemplateFieldOption | undefined>(
valueInFormIsCustom ? 'Custom' : 'Existing'
);
const [_, copyToClipboard] = useCopyToClipboard();
const templateOptions: Array<SelectableValue<TemplateFieldOption>> = [
{
label: 'Select existing template',
ariaLabel: 'Select existing template',
value: 'Existing',
description: `Select a single template and preview it, or copy it to paste it in the custom tab. ${templateOption === 'Existing' ? 'Clicking Save will save your changes to the selected template.' : ''}`,
},
{
label: `Enter custom ${option.label.toLowerCase()}`,
ariaLabel: `Enter custom ${option.label.toLowerCase()}`,
value: 'Custom',
description: `Enter custom ${option.label.toLowerCase()}. ${templateOption === 'Custom' ? 'Clicking Save will save the custom value only.' : ''}`,
},
];
useEffect(() => {
if (template) {
setInputToUpdate(getUseTemplateText(template.name));
if (template?.value?.name) {
setCustomTemplateValue(getUseTemplateText(template.value.name));
}
}, [template]);
function onCustomTemplateChange(customInput: string) {
setInputToUpdateCustom(customInput);
setCustomTemplateValue(customInput);
}
const onTemplateOptionChange = (option: TemplateFieldOption) => {
@ -164,21 +163,14 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
return getTemplateOptions(data, defaultTemplates);
}, [data, defaultTemplates, isLoading, error]);
// if we are using only one template, we should settemplate to that template
useEffect(() => {
if (Boolean(valueInForm)) {
if (matchesOnlyOneTemplate(valueInForm)) {
const name = getTemplateName(valueInForm);
setTemplate({
name,
content: getContentFromOptions(name, options),
});
} else {
// if it's empty we default to select existing template
setTemplateOption('Custom');
}
const defaultTemplateValue = useMemo(() => {
if (!options.length || !Boolean(valueInForm) || !matchesOnlyOneTemplate(valueInForm)) {
return null;
}
}, [valueInForm, setTemplate, setTemplateOption, options]);
const nameOfTemplateInForm = getTemplateName(valueInForm);
return options.find((option) => option.label === nameOfTemplateInForm) || null;
}, [options, valueInForm]);
if (error) {
return <div>Error loading templates</div>;
@ -202,26 +194,29 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
<Select<Template>
data-testid="existing-templates-selector"
placeholder="Choose template"
aria-label="Choose template"
onChange={(value: SelectableValue<Template>, _) => {
setTemplate(value?.value);
setTemplate(value);
}}
options={options}
width={50}
value={template ? { label: template.name, value: template } : undefined}
defaultValue={defaultTemplateValue}
/>
<IconButton
tooltip="Copy selected template to clipboard. You can use it in the custom tab."
onClick={() => copyToClipboard(getUseTemplateText(template?.name ?? ''))}
onClick={() =>
copyToClipboard(getUseTemplateText(template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''))
}
name="copy"
/>
</Stack>
<TemplateContentAndPreview
templateContent={template?.content ?? ''}
templateContent={template?.value?.content ?? defaultTemplateValue?.value?.content ?? ''}
payload={defaultPayloadString}
templateName={template?.name ?? ''}
templateName={template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''}
setPayloadFormatError={() => {}}
className={cx(styles.templatePreview, styles.minEditorSize)}
payloadFormatError={null}
@ -231,7 +226,7 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
<OptionCustomfield
option={option}
onCustomTemplateChange={onCustomTemplateChange}
initialValue={inputToUpdateCustom}
initialValue={customTemplateValue}
/>
)}
</Stack>
@ -242,13 +237,15 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
<Button
variant="primary"
onClick={() => {
onSelect(templateOption === 'Custom' ? inputToUpdateCustom : inputToUpdate);
onClose();
if (templateOption === 'Custom') {
trackUseCustomInputInTemplate();
onSelect(customTemplateValue);
} else {
trackUseSingleTemplateInInput();
const name = template?.value?.name ?? defaultTemplateValue?.value?.name ?? '';
onSelect(getUseTemplateText(name));
}
return onClose();
}}
>
Save
@ -267,31 +264,21 @@ function OptionCustomfield({
onCustomTemplateChange(customInput: string): void;
initialValue: string;
}) {
switch (option.element) {
case 'textarea':
const id = `custom-template-${option.label}`;
return (
<Stack direction="row" gap={1} alignItems="center">
<Stack direction="column" gap={1}>
<Label htmlFor={id}>
<Trans i18nKey="alerting.contact-points.custom-template-value">Custom template value</Trans>
</Label>
<TextArea
id={id}
label="Custom template"
placeholder={option.placeholder}
onChange={(e) => onCustomTemplateChange(e.currentTarget.value)}
defaultValue={initialValue}
/>
</Stack>
);
case 'input':
return (
<Stack direction="row" gap={1} alignItems="center">
<Input
type={option.inputType}
placeholder={option.placeholder}
onChange={(e) => onCustomTemplateChange(e.currentTarget.value)}
defaultValue={initialValue}
/>
</Stack>
);
default:
return null;
}
}
interface WrapWithTemplateSelectionProps extends PropsWithChildren {

@ -289,7 +289,7 @@ export const grafanaAlertNotifiersMock: NotifierDTO[] = [
label: 'Message',
description:
'Optional message. You can use templates to customize this field. Using a custom message will replace the default message',
placeholder: '',
placeholder: '{{ template "default.message" . }}',
propertyName: 'message',
selectOptions: null,
showWhen: {

@ -124,6 +124,8 @@ const grafanaAlertmanagerConfig: AlertManagerCortexConfig = {
addresses:
'gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com',
singleEmail: false,
message: '{{ template "slack-template" . }}',
subject: 'some custom value',
},
secureFields: {},
},

@ -1,5 +1,6 @@
import { http, HttpResponse, JsonBodyType, StrictResponse } from 'msw';
import { TemplatesTestPayload } from 'app/features/alerting/unified/api/templateApi';
import receiversMock from 'app/features/alerting/unified/components/contact-points/__mocks__/receivers.mock.json';
import { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks';
import { defaultGrafanaAlertingConfigurationStatusResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi';
@ -135,9 +136,17 @@ export const updateAlertmanagerConfigHandler = (responseOverride?: typeof ALERTM
});
const getGrafanaAlertmanagerTemplatePreview = () =>
http.post('/api/alertmanager/grafana/config/api/v1/templates/test', () =>
// TODO: Scaffold out template preview response as needed by tests
HttpResponse.json({})
http.post<never, TemplatesTestPayload>(
'/api/alertmanager/grafana/config/api/v1/templates/test',
async ({ request }) => {
const body = await request.json();
if (body?.template.startsWith('{{')) {
return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.name}` }] });
}
return HttpResponse.json({});
}
);
const getReceiversHandler = () =>

@ -140,6 +140,7 @@
"contact-point": "Contact Point",
"contact-points": {
"create": "Create contact point",
"custom-template-value": "Custom template value",
"delete-reasons": {
"heading": "Contact point cannot be deleted for the following reasons:",
"no-permissions": "You do not have the required permission to delete this contact point",

@ -140,6 +140,7 @@
"contact-point": "Cőʼnŧäčŧ Pőįʼnŧ",
"contact-points": {
"create": "Cřęäŧę čőʼnŧäčŧ pőįʼnŧ",
"custom-template-value": "Cūşŧőm ŧęmpľäŧę väľūę",
"delete-reasons": {
"heading": "Cőʼnŧäčŧ pőįʼnŧ čäʼnʼnőŧ þę đęľęŧęđ ƒőř ŧĥę ƒőľľőŵįʼnģ řęäşőʼnş:",
"no-permissions": "Ÿőū đő ʼnőŧ ĥävę ŧĥę řęqūįřęđ pęřmįşşįőʼn ŧő đęľęŧę ŧĥįş čőʼnŧäčŧ pőįʼnŧ",

Loading…
Cancel
Save