Alerting: Template selector in contact points form (#87689)

* WIP

* Refactor and update how we display these fields in the form

* Add test for getTemplateOptions and udpate parseTemplates to handle minus simbol

* fix betterer

* Fix wrapper

* Create new usePreviewTemplate to be reused from TemplatePreview and TemplateContentAndPreview

* remove unnecessary check

* track interactions

* Include the whole content of the template in the preview

* Update parseTemplates function to return default templates

* handle nested templates in parseTemplates function

* Missing border fixed, whitespaces preserved and no empty space at the bottom

* remove unused styles and add a comment in test

* Add missing error in getPreviewResults

* fix styles for template selector containers

* Alerting: PR feedback to move default templates into RTKQ (#88172)

Move default templates to RTKQ API + constant

* move parseTemplates to a utils file and refactor last part of this function

* Keep selected options when loading exising input and when switching between tabs

* Update descritpion in tabs

* Fix not previewing when loading existing values

* Update text addressing Brenda's feedback

* Add test for matchesOnlyOneTemplate function

* Add minheight to viewer container and fix getContentFromOptions function

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar 2024-05-24 13:35:48 +02:00 committed by GitHub
parent 6775bcb0a3
commit caeb9bcea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 883 additions and 56 deletions

View File

@ -28,7 +28,7 @@ export const LogMessages = {
const { logInfo, logError, logMeasurement } = createMonitoringLogger('features.alerting', { module: 'Alerting' });
export { logInfo, logError, logMeasurement };
export { logError, logInfo, logMeasurement };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
@ -245,6 +245,16 @@ export function trackSwitchToPoliciesRouting() {
reportInteraction('grafana_alerting_switch_to_policies_routing');
}
export function trackEditInputWithTemplate() {
reportInteraction('grafana_alerting_contact_point_form_edit_input_with_template');
}
export function trackUseCustomInputInTemplate() {
reportInteraction('grafana_alerting_contact_point_form_use_custom_input_in_template');
}
export function trackUseSingleTemplateInInput() {
reportInteraction('grafana_alerting_contact_point_form_use_single_template_in_input');
}
export type AlertRuleTrackingProps = {
user_id: number;
grafana_version?: string;

View File

@ -1,3 +1,8 @@
import { Template } from 'app/features/alerting/unified/components/receivers/form/fields/TemplateSelector';
import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants';
import { parseTemplates } from '../components/receivers/form/fields/utils';
import { alertingApi } from './alertingApi';
export const previewTemplateUrl = `/api/alertmanager/grafana/config/api/v1/templates/test`;
@ -34,6 +39,12 @@ export const templatesApi = alertingApi.injectEndpoints({
method: 'POST',
}),
}),
getDefaultTemplates: build.query<Template[], void>({
queryFn: async () => {
const data = parseTemplates(DEFAULT_TEMPLATES);
return { data };
},
}),
}),
});

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Stack, Label } from '@grafana/ui';
import { Label, Stack, useStyles2 } from '@grafana/ui';
export function EditorColumnHeader({ label, actions }: { label: string; actions?: React.ReactNode }) {
const styles = useStyles2(editorColumnStyles);

View File

@ -471,4 +471,4 @@ const defaultPayload: TestTemplateAlert[] = [
},
];
const defaultPayloadString = JSON.stringify(defaultPayload, null, 2);
export const defaultPayloadString = JSON.stringify(defaultPayload, null, 2);

View File

@ -1,23 +1,18 @@
import { css, cx } from '@emotion/css';
import { compact, uniqueId } from 'lodash';
import React, { useCallback, useEffect } from 'react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2, Alert, Box } from '@grafana/ui';
import { Alert, Box, Button, useStyles2 } from '@grafana/ui';
import {
AlertField,
TemplatePreviewErrors,
TemplatePreviewResponse,
TemplatePreviewResult,
usePreviewTemplateMutation,
} from '../../api/templateApi';
import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi';
import { stringifyErrorLike } from '../../utils/misc';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import type { TemplateFormValues } from './TemplateForm';
import { usePreviewTemplate } from './usePreviewTemplate';
export function TemplatePreview({
payload,
@ -38,23 +33,14 @@ export function TemplatePreview({
const templateContent = watch('content');
const [trigger, { data, error: previewError, isLoading }] = usePreviewTemplateMutation();
const {
data,
isLoading,
onPreview,
error: previewError,
} = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
const previewToRender = getPreviewResults(previewError, payloadFormatError, data);
const onPreview = useCallback(() => {
try {
const alertList: AlertField[] = JSON.parse(payload);
JSON.stringify([...alertList]); // check if it's iterable, in order to be able to add more data
trigger({ template: templateContent, alerts: alertList, name: templateName });
setPayloadFormatError(null);
} catch (e) {
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
}
}, [templateContent, templateName, payload, setPayloadFormatError, trigger]);
useEffect(() => onPreview(), [onPreview]);
return (
<div className={cx(styles.container, className)}>
<EditorColumnHeader
@ -124,11 +110,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
height: 'inherit',
}),
box: css({
display: 'flex',
flexDirection: 'column',
borderBottom: `1px solid ${theme.colors.border.medium}`,
height: 'inherit',
}),
header: css({
fontSize: theme.typography.bodySmall.fontSize,

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import React, { FC, useEffect } from 'react';
import { useFormContext, FieldError, DeepMap, Controller } from 'react-hook-form';
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Field, Input, RadioButtonList, Select, TextArea, useStyles2 } from '@grafana/ui';
@ -11,6 +11,7 @@ import { KeyValueMapInput } from './KeyValueMapInput';
import { StringArrayInput } from './StringArrayInput';
import { SubformArrayField } from './SubformArrayField';
import { SubformField } from './SubformField';
import { WrapWithTemplateSelection } from './TemplateSelector';
interface Props {
defaultValue: any;
@ -89,7 +90,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
customValidator,
}) => {
const styles = useStyles2(getStyles);
const { control, register, unregister, getValues } = useFormContext();
const { control, register, unregister, getValues, setValue } = useFormContext();
const name = `${pathPrefix}${option.propertyName}`;
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
@ -99,6 +100,13 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
},
[unregister, name]
);
const useTemplates = option.placeholder.includes('{{ template');
function onSelectTemplate(template: string) {
setValue(name, template);
}
switch (option.element) {
case 'checkbox':
return (
@ -114,22 +122,29 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
);
case 'input':
return (
<Input
id={id}
readOnly={readOnly || determineReadOnly(option, getValues, pathIndex)}
invalid={invalid}
type={option.inputType}
{...register(name, {
required: determineRequired(option, getValues, pathIndex),
validate: {
validationRule: (v) =>
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
customValidator: (v) => (customValidator ? customValidator(v) : true),
},
setValueAs: option.setValueAs,
})}
placeholder={option.placeholder}
/>
<WrapWithTemplateSelection
useTemplates={useTemplates}
option={option}
name={name}
onSelectTemplate={onSelectTemplate}
>
<Input
id={id}
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
invalid={invalid}
type={option.inputType}
{...register(name, {
required: determineRequired(option, getValues, pathIndex),
validate: {
validationRule: (v) =>
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
customValidator: (v) => (customValidator ? customValidator(v) : true),
},
setValueAs: option.setValueAs,
})}
placeholder={option.placeholder}
/>
</WrapWithTemplateSelection>
);
case 'select':
@ -178,17 +193,24 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
);
case 'textarea':
return (
<TextArea
id={id}
readOnly={readOnly}
invalid={invalid}
placeholder={option.placeholder}
{...register(name, {
required: option.required ? 'Required' : false,
validate: (v) =>
option.validationRule !== '' ? validateOption(v, option.validationRule, option.required) : true,
})}
/>
<WrapWithTemplateSelection
useTemplates={useTemplates}
option={option}
name={name}
onSelectTemplate={onSelectTemplate}
>
<TextArea
id={id}
readOnly={readOnly || useTemplates}
invalid={invalid}
placeholder={option.placeholder}
{...register(name, {
required: option.required ? 'Required' : false,
validate: (v) =>
option.validationRule !== '' ? validateOption(v, option.validationRule, option.required) : true,
})}
/>
</WrapWithTemplateSelection>
);
case 'string_array':
return (

View File

@ -0,0 +1,94 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Box, useStyles2 } from '@grafana/ui';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
import { TemplateEditor } from '../../TemplateEditor';
import { getPreviewResults } from '../../TemplatePreview';
import { usePreviewTemplate } from '../../usePreviewTemplate';
export function TemplateContentAndPreview({
payload,
templateContent,
templateName,
payloadFormatError,
setPayloadFormatError,
className,
}: {
payload: string;
templateName: string;
payloadFormatError: string | null;
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
className?: string;
templateContent: string;
}) {
const styles = useStyles2(getStyles);
const { selectedAlertmanager } = useAlertmanager();
const isGrafanaAlertManager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
const previewToRender = getPreviewResults(error, payloadFormatError, data);
return (
<div className={cx(className, styles.mainContainer)}>
<div className={styles.container}>
<EditorColumnHeader label="Template content" />
<Box flex={1}>
<div className={styles.viewerContainer({ height: 400 })}>
<AutoSizer>
{({ width, height }) => (
<TemplateEditor
value={templateContent}
containerStyles={styles.editorContainer}
width={width}
height={height}
readOnly
/>
)}
</AutoSizer>
</div>
</Box>
</div>
{isGrafanaAlertManager && (
<div className={styles.container}>
<EditorColumnHeader label="Preview with the default payload" />
<Box flex={1}>
<div className={styles.viewerContainer({ height: 'minHeight' })}>{previewToRender}</div>
</Box>
</div>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
mainContainer: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
container: css({
label: 'template-preview-container',
display: 'flex',
flexDirection: 'column',
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.medium}`,
}),
viewerContainer: ({ height }: { height: number | string }) =>
css({
height,
overflow: 'auto',
backgroundColor: theme.colors.background.primary,
}),
editorContainer: css({
width: 'fit-content',
border: 'none',
}),
});

View File

@ -0,0 +1,46 @@
import { DEFAULT_TEMPLATES } from 'app/features/alerting/unified/utils/template-constants';
import { getTemplateOptions } from './TemplateSelector';
import { parseTemplates } from './utils';
describe('getTemplateOptions function', () => {
it('should return the last template when there are duplicates', () => {
const templateFiles = {
file1:
'{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}',
// duplicated define, the last one should be returned
file2:
'{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s) this is the last one{{ end }}',
file3:
'{{ define "email.subject" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}',
// define with a minus sign
file4: '{{ define "template_with_minus" -}}{{ .Annotations.summary }}{{- end }}',
file5: '',
//nested templates
file6: `{{ define "nested" }}
Main Template Content
{{ template "sub1" }}
{{ template "sub2" }}
{{ end }}
{{ define "sub1" }}
Sub Template 1 Content
{{ end }}
{{ define "sub2" }}
Sub Template 2 Content
{{ end }}`,
};
const defaultTemplates = parseTemplates(DEFAULT_TEMPLATES);
const result = getTemplateOptions(templateFiles, defaultTemplates);
const template1Matches = result.filter((option) => option.label === 'template1');
expect(template1Matches).toHaveLength(1);
expect(template1Matches[0].value?.content).toMatch(/this is the last one/i);
const file5Matches = result.filter((option) => option.label === 'file5');
expect(file5Matches).toHaveLength(0);
expect(result).toMatchSnapshot();
});
});

View File

@ -0,0 +1,376 @@
import { css, cx } from '@emotion/css';
import React, { PropsWithChildren, useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useCopyToClipboard } from 'react-use';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Button,
Drawer,
IconButton,
Input,
RadioButtonGroup,
Select,
Stack,
Text,
TextArea,
useStyles2,
} from '@grafana/ui';
import {
trackEditInputWithTemplate,
trackUseCustomInputInTemplate,
trackUseSingleTemplateInInput,
} from 'app/features/alerting/unified/Analytics';
import { templatesApi } from 'app/features/alerting/unified/api/templateApi';
import { useAlertmanagerConfig } from 'app/features/alerting/unified/hooks/useAlertmanagerConfig';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { NotificationChannelOption } from 'app/types';
import { defaultPayloadString } from '../../TemplateForm';
import { TemplateContentAndPreview } from './TemplateContentAndPreview';
import { getTemplateName, getUseTemplateText, matchesOnlyOneTemplate, parseTemplates } from './utils';
interface TemplatesPickerProps {
onSelect: (temnplate: string) => void;
option: NotificationChannelOption;
valueInForm: string;
}
export function TemplatesPicker({ onSelect, option, valueInForm }: TemplatesPickerProps) {
const [showTemplates, setShowTemplates] = React.useState(false);
const onClick = () => {
setShowTemplates(true);
trackEditInputWithTemplate();
};
return (
<>
<Button
icon="edit"
tooltip={'Edit 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>
)}
</>
);
}
type TemplateFieldOption = 'Existing' | 'Custom';
export function getTemplateOptions(templateFiles: Record<string, string>, defaultTemplates: Template[] = []) {
// Add default templates
const templateMap = new Map<string, SelectableValue<Template>>();
Object.entries(templateFiles).forEach(([_, content]) => {
const templates: Template[] = parseTemplates(content);
templates.forEach((template) => {
templateMap.set(template.name, {
label: template.name,
value: {
name: template.name,
content: template.content,
},
});
});
});
// Add default templates to the map
defaultTemplates.forEach((template) => {
templateMap.set(template.name, {
label: template.name,
value: {
name: template.name,
content: template.content,
},
});
});
// 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;
content: string;
}
interface TemplateSelectorProps {
onSelect: (template: string) => void;
onClose: () => void;
option: NotificationChannelOption;
valueInForm: string;
}
function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSelectorProps) {
const styles = useStyles2(getStyles);
const useGetDefaultTemplatesQuery = templatesApi.endpoints.getDefaultTemplates.useQuery;
const [template, setTemplate] = React.useState<Template | undefined>(undefined);
const [inputToUpdate, setInputToUpdate] = React.useState<string>('');
const [inputToUpdateCustom, setInputToUpdateCustom] = React.useState<string>(valueInForm);
const { selectedAlertmanager } = useAlertmanager();
const { data, error } = useAlertmanagerConfig(selectedAlertmanager);
const { data: defaultTemplates } = useGetDefaultTemplatesQuery();
const [templateOption, setTemplateOption] = React.useState<TemplateFieldOption>('Existing');
const [_, copyToClipboard] = useCopyToClipboard();
const templateOptions: Array<SelectableValue<TemplateFieldOption>> = [
{
label: 'Selecting 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()}`,
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));
}
}, [template]);
function onCustomTemplateChange(customInput: string) {
setInputToUpdateCustom(customInput);
}
const onTemplateOptionChange = (option: TemplateFieldOption) => {
setTemplateOption(option);
};
const options = useMemo(() => {
if (!defaultTemplates) {
return [];
}
return getTemplateOptions(data?.template_files ?? {}, defaultTemplates);
}, [data, defaultTemplates]);
// if we are using only one template, we should settemplate to that template
useEffect(() => {
if (matchesOnlyOneTemplate(valueInForm)) {
const name = getTemplateName(valueInForm);
setTemplate({
name,
content: getContentFromOptions(name, options),
});
} else {
if (Boolean(valueInForm)) {
// if it's empty we default to select existing template
setTemplateOption('Custom');
}
}
}, [valueInForm, setTemplate, setTemplateOption, options]);
if (error) {
return <div>Error loading templates</div>;
}
if (!data) {
return <div>Loading...</div>;
}
return (
<Stack direction="column" gap={1} justifyContent="space-between" height="100%">
<Stack direction="column" gap={1}>
<RadioButtonGroup
options={templateOptions}
value={templateOption}
onChange={onTemplateOptionChange}
className={styles.templateTabOption}
/>
{templateOption === 'Existing' ? (
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1} alignItems="center">
<Select<Template>
aria-label="Template"
onChange={(value: SelectableValue<Template>, _) => {
setTemplate(value?.value);
}}
options={options}
width={50}
value={template ? { label: template.name, value: template } : undefined}
/>
<IconButton
tooltip="Copy selected template to clipboard. You can use it in the custom tab."
onClick={() => copyToClipboard(getUseTemplateText(template?.name ?? ''))}
name="copy"
/>
</Stack>
<TemplateContentAndPreview
templateContent={template?.content ?? ''}
payload={defaultPayloadString}
templateName={template?.name ?? ''}
setPayloadFormatError={() => {}}
className={cx(styles.templatePreview, styles.minEditorSize)}
payloadFormatError={null}
/>
</Stack>
) : (
<OptionCustomfield
option={option}
onCustomTemplateChange={onCustomTemplateChange}
initialValue={inputToUpdateCustom}
/>
)}
</Stack>
<div className={styles.actions}>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
onSelect(templateOption === 'Custom' ? inputToUpdateCustom : inputToUpdate);
onClose();
if (templateOption === 'Custom') {
trackUseCustomInputInTemplate();
} else {
trackUseSingleTemplateInInput();
}
}}
>
Save
</Button>
</div>
</Stack>
);
}
function OptionCustomfield({
option,
onCustomTemplateChange,
initialValue,
}: {
option: NotificationChannelOption;
onCustomTemplateChange(customInput: string): void;
initialValue: string;
}) {
switch (option.element) {
case 'textarea':
return (
<Stack direction="row" gap={1} alignItems="center">
<TextArea
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 {
useTemplates: boolean;
onSelectTemplate: (template: string) => void;
option: NotificationChannelOption;
name: string;
}
export function WrapWithTemplateSelection({
useTemplates,
onSelectTemplate,
option,
name,
children,
}: WrapWithTemplateSelectionProps) {
const { getValues } = useFormContext();
const value: string = getValues(name) ?? '';
const emptyValue = value === '' || value === undefined;
const onlyOneTemplate = value ? matchesOnlyOneTemplate(value) : false;
const styles = useStyles2(getStyles);
// if the placeholder does not contain a template, we don't need to show the template picker
if (!option.placeholder.includes('{{ template ')) {
return <>{children}</>;
}
// Otherwise, we can use templates on this field
// if the value is empty, we only show the template picker
if (emptyValue) {
return (
<div className={styles.inputContainer}>
<Stack direction="row" gap={1} alignItems="center">
{useTemplates && (
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
)}
</Stack>
</div>
);
}
if (onlyOneTemplate) {
return (
<div className={styles.inputContainer}>
<Stack direction="row" gap={1} alignItems="center">
<Text variant="bodySmall">{`Template: ${getTemplateName(value)}`}</Text>
{useTemplates && (
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
)}
</Stack>
</div>
);
}
// custom template field
return (
<div className={styles.inputContainer}>
<Stack direction="row" gap={1} alignItems="center">
{children}
{useTemplates && (
<TemplatesPicker onSelect={onSelectTemplate} option={option} valueInForm={getValues(name) ?? ''} />
)}
</Stack>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
actions: css({
flex: 0,
justifyContent: 'flex-end',
display: 'flex',
gap: theme.spacing(1),
}),
templatePreview: css({
flex: 1,
display: 'flex',
}),
templateTabOption: css({
width: 'fit-content',
}),
minEditorSize: css({
minHeight: 300,
minWidth: 300,
}),
inputContainer: css({
marginTop: theme.spacing(1.5),
}),
});

View File

@ -0,0 +1,73 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getTemplateOptions function should return the last template when there are duplicates 1`] = `
[
{
"label": "template1",
"value": {
"content": "{{ define "template1" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s) this is the last one{{ end }}",
"name": "template1",
},
},
{
"label": "email.subject",
"value": {
"content": "{{ define "email.subject" }}{{ len .Alerts.Firing }} firing alert(s), {{ len .Alerts.Resolved }} resolved alert(s){{ end }}",
"name": "email.subject",
},
},
{
"label": "template_with_minus",
"value": {
"content": "{{ define "template_with_minus" -}}{{ .Annotations.summary }}{{- end }}",
"name": "template_with_minus",
},
},
{
"label": "nested",
"value": {
"content": "{{ define "nested" }}
Main Template Content
{{ template "sub1" }}
{{ template "sub2" }}
{{ end }}
{{ define "sub1" }}
Sub Template 1 Content
{{ end }}
{{ define "sub2" }}
Sub Template 2 Content
{{ end }}",
"name": "nested",
},
},
{
"label": "default.title",
"value": {
"content": "{{ define "default.title" }}{{ template "__subject" . }}{{ end }}",
"name": "default.title",
},
},
{
"label": "default.message",
"value": {
"content": "{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}",
"name": "default.message",
},
},
{
"label": "teams.default.message",
"value": {
"content": "{{ define "teams.default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
{{ template "__teams_text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
{{ template "__teams_text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}",
"name": "teams.default.message",
},
},
]
`;

View File

@ -0,0 +1,106 @@
/**
* This function parses the template content and returns an array of Template objects.
* Each Template object represents a single template definition found in the content.
*
* There are several rules for parsing the template content:
* - The template content may use the "-" symbol for whitespace trimming. If a template's left delimiter ("{{")
* is immediately followed by a "-" and whitespace, all trailing whitespace is removed from the preceding text.
* - Similarly, if the right delimiter ("}}") is immediately preceded by whitespace and a "-", all leading whitespace
* is removed from the following text. In these cases, the whitespace must be present for the trimming to occur.
* - The template content may contain nested templates. The nested templates are appended to the main template content,
* and the nested templates are removed from the list of templates.
* - We don't return templates with names starting with "__" as they are considered internal templates.
*
* @param templatesString is a string containing the template content. Each template is defined within
* "{{ define "templateName" }}" and "{{ end }}" delimiters.But it may also contain nested templates.
*/
import { Template } from './TemplateSelector';
export function parseTemplates(templatesString: string): Template[] {
const templates: Record<string, Template> = {};
const stack: Array<{ type: string; startIndex: number; name?: string }> = [];
const regex = /{{(-?\s*)(define|end|if|range|else|with|template)(\s*.*?)?(-?\s*)}}/gs;
let match;
let currentIndex = 0;
while ((match = regex.exec(templatesString)) !== null) {
const [, , keyword, middleContent] = match;
currentIndex = match.index;
if (keyword === 'define') {
const nameMatch = middleContent?.match(/"([^"]+)"/);
if (nameMatch) {
stack.push({ type: 'define', startIndex: currentIndex, name: nameMatch[1] });
}
} else if (keyword === 'end') {
let top = stack.pop();
while (top && top.type !== 'define' && top.type !== 'if' && top.type !== 'range' && top.type !== 'with') {
top = stack.pop();
}
if (top) {
const endIndex = regex.lastIndex;
if (top.type === 'define' && !top.name?.startsWith('__')) {
templates[top.name!] = {
name: top.name!,
content: templatesString.slice(top.startIndex, endIndex),
};
}
}
} else if (keyword === 'if' || keyword === 'range' || keyword === 'else' || keyword === 'with') {
stack.push({ type: keyword, startIndex: currentIndex });
}
}
// Append sub-template content to the end of the main template and remove sub-templates from the list
for (const template of Object.values(templates)) {
const regex = /{{ template "([^"]+)" }}/g;
let match;
while ((match = regex.exec(template.content)) !== null) {
const name = match[1];
if (templates[name]?.content) {
template.content += '\n' + templates[name]?.content;
delete templates[name]; // Remove the sub-template from the list
}
}
}
return Object.values(templates);
}
export function getUseTemplateText(templateName: string) {
return `{{ template "${templateName}" . }}`;
}
export function getTemplateName(useTemplateText: string) {
const match = useTemplateText.match(/\{\{\s*template\s*"(.*)"\s*\.\s*\}\}/);
return match ? match[1] : '';
}
/* This function checks if the a field value contains only one template usage
for example:
"{{ template "templateName" . }}"" returns true
but "{{ template "templateName" . }} some text {{ template "templateName" . }}"" returns false
and "{{ template "templateName" . }} some text" some text returns false
**/
export function matchesOnlyOneTemplate(fieldValue: string) {
const pattern = /\{\{\s*template\s*".*?"\s*\.\s*\}\}/g;
const matches = fieldValue.match(pattern);
if (matches?.length !== 1) {
return false;
}
// Split the content by the template pattern
const parts = fieldValue.split(pattern);
// Check if there is any non-whitespace text outside the template pattern
for (const part of parts) {
if (part.trim() !== '') {
return false;
}
}
return true;
}

View File

@ -1,5 +1,6 @@
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import { matchesOnlyOneTemplate } from './fields/utils';
import { DeprecatedAuthHTTPConfig, HTTPAuthConfig, normalizeFormValues } from './util';
describe('normalizeFormValues', () => {
@ -61,3 +62,25 @@ function createContactPoint(httpConfig: DeprecatedAuthHTTPConfig | HTTPAuthConfi
return config;
}
describe('matchesOnlyOneTemplate', () => {
it('should return true when there is only one template and no other text', () => {
const fieldValue = '{{ template "nested" . }}';
expect(matchesOnlyOneTemplate(fieldValue)).toBe(true);
});
it('should return false when there is more than one template', () => {
const fieldValue = '{{ template "nested" . }}{{ template "nested2" . }}';
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
});
it('should return false when there is other text outside the template', () => {
const fieldValue = '{{ template "nested" . }} some other text';
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
});
it('should return false when there is no template', () => {
const fieldValue = 'some other text';
expect(matchesOnlyOneTemplate(fieldValue)).toBe(false);
});
});

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect } from 'react';
import { AlertField, usePreviewTemplateMutation } from '../../api/templateApi';
export function usePreviewTemplate(
templateContent: string,
templateName: string,
payload: string,
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void
) {
const [trigger, { data, error, isLoading }] = usePreviewTemplateMutation();
const onPreview = useCallback(() => {
try {
const alertList: AlertField[] = JSON.parse(payload);
JSON.stringify([...alertList]); // check if it's iterable, in order to be able to add more data
trigger({ template: templateContent, alerts: alertList, name: templateName });
setPayloadFormatError(null);
} catch (e) {
setPayloadFormatError(e instanceof Error ? e.message : 'Invalid JSON.');
}
}, [templateContent, templateName, payload, setPayloadFormatError, trigger]);
useEffect(() => onPreview(), [onPreview]);
return { data, error, isLoading, onPreview };
}

View File

@ -0,0 +1,51 @@
// We need to use this constat until we have an API to get the default templates
export const DEFAULT_TEMPLATES = `{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ if gt (.Alerts.Resolved | len) 0 }}, RESOLVED:{{ .Alerts.Resolved | len }}{{ end }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }}
{{ define "__text_values_list" }}{{ if len .Values }}{{ $first := true }}{{ range $refID, $value := .Values -}}
{{ if $first }}{{ $first = false }}{{ else }}, {{ end }}{{ $refID }}={{ $value }}{{ end -}}
{{ else }}[no value]{{ end }}{{ end }}
{{ define "__text_alert_list" }}{{ range . }}
Value: {{ template "__text_values_list" . }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}{{ if gt (len .GeneratorURL) 0 }}Source: {{ .GeneratorURL }}
{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: {{ .SilenceURL }}
{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: {{ .DashboardURL }}
{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: {{ .PanelURL }}
{{ end }}{{ end }}{{ end }}
{{ define "default.title" }}{{ template "__subject" . }}{{ end }}
{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}
{{ define "__teams_text_alert_list" }}{{ range . }}
Value: {{ template "__text_values_list" . }}
Labels:
{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}
Annotations:
{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}
{{ end }}
{{ if gt (len .GeneratorURL) 0 }}Source: [{{ .GeneratorURL }}]({{ .GeneratorURL }})
{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: [{{ .SilenceURL }}]({{ .SilenceURL }})
{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: [{{ .DashboardURL }}]({{ .DashboardURL }})
{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: [{{ .PanelURL }}]({{ .PanelURL }})
{{ end }}
{{ end }}{{ end }}
{{ define "teams.default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing**
{{ template "__teams_text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }}
{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved**
{{ template "__teams_text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }}`;