Alerting: Template preview enhancements (#103817)
API Changes:
- Fixes validation in template CRUD API to be closer to how the running
alertmanager will use the template. Should remove some incorrect
validation errors.
- Adds some missing default placeholder labels to receiver testing that
are used during template testing but missing during receiver testing
Template Preview:
- Replaced basic preview with a readonly CodeEditor for better whitespace
and alignment clarity (also adds support for future syntax highlighting
in template previews for upcoming webhook payload templates)
Template Selector (Receiver Form):
- Refactored to use same components as Template editor for preview.
- Fixed preview to work with multi-definition templates
- Fixed copy to correctly copy the template contents instead of
{{ template "<name>" . }}.
Template Editor:
- Fixed detection of when to display functions vs snippets in multi-line
expressions
This commit is contained in:
@@ -2,10 +2,8 @@ package definitions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
tmplhtml "html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
tmpltext "text/template"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -35,17 +33,12 @@ func (t *NotificationTemplate) Validate() error {
|
||||
t.Template = content
|
||||
|
||||
// Validate template contents. We try to stick as close to what will actually happen when the templates are parsed
|
||||
// by the alertmanager as possible. That means parsing with both the text and html parsers and making sure we set
|
||||
// the template name and options.
|
||||
ttext := tmpltext.New(t.Name).Option("missingkey=zero")
|
||||
ttext.Funcs(tmpltext.FuncMap(template.DefaultFuncs))
|
||||
if _, err := ttext.Parse(t.Template); err != nil {
|
||||
return fmt.Errorf("invalid template: %w", err)
|
||||
// by the alertmanager as possible.
|
||||
tmpl, err := template.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create template: %w", err)
|
||||
}
|
||||
|
||||
thtml := tmplhtml.New(t.Name).Option("missingkey=zero")
|
||||
thtml.Funcs(tmplhtml.FuncMap(template.DefaultFuncs))
|
||||
if _, err := thtml.Parse(t.Template); err != nil {
|
||||
if err := tmpl.Parse(strings.NewReader(t.Template)); err != nil {
|
||||
return fmt.Errorf("invalid template: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,6 @@ func TestValidateNotificationTemplates(t *testing.T) {
|
||||
expError: errors.New("invalid template: template: Different name than definition:1: template: multiple definition of template \"Alert Instance Template\""),
|
||||
},
|
||||
{
|
||||
// This is fine as long as the template name is different from the definition, it just ignores the extra text.
|
||||
name: "Extra text outside definition block - different template name and definition",
|
||||
template: NotificationTemplate{
|
||||
Name: "Different name than definition",
|
||||
@@ -452,16 +451,16 @@ func TestValidateNotificationTemplates(t *testing.T) {
|
||||
expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
|
||||
expError: nil,
|
||||
},
|
||||
// This test used to error because our template code parsed the template with the filename as template name.
|
||||
// However, we have since moved away from this. We keep this test to ensure we don't regress.
|
||||
{
|
||||
// This is NOT fine as the template name is the same as the definition.
|
||||
// GO template parser will treat it as if it's wrapped in {{ define "Alert Instance Template" }}, thus creating a duplicate definition.
|
||||
name: "Extra text outside definition block - same template name and definition",
|
||||
template: NotificationTemplate{
|
||||
Name: "Alert Instance Template",
|
||||
Template: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
|
||||
Provenance: "test",
|
||||
},
|
||||
expError: errors.New("invalid template: template: Alert Instance Template:1: template: multiple definition of template \"Alert Instance Template\""),
|
||||
expContent: `{{ define "Alert Instance Template" }}\nFiring: {{ .Labels.alertname }}\nSilence: {{ .SilenceURL }}\n{{ end }}[what is this?]`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ type TestTemplatesResults = alertingNotify.TestTemplatesResults
|
||||
|
||||
var (
|
||||
DefaultLabels = map[string]string{
|
||||
prometheusModel.AlertNameLabel: `alert title`,
|
||||
alertingModels.FolderTitleLabel: `folder title`,
|
||||
prometheusModel.AlertNameLabel: `TestAlert`,
|
||||
alertingModels.FolderTitleLabel: `Test Folder`,
|
||||
}
|
||||
DefaultAnnotations = map[string]string{
|
||||
alertingModels.ValuesAnnotation: `{"B":22,"C":1}`,
|
||||
|
||||
@@ -84,7 +84,7 @@ CommonAnnotations: {{ range .CommonAnnotations.SortedPairs }}{{ .Name }}={{ .Val
|
||||
expected: TestTemplatesResults{
|
||||
Results: []alertingNotify.TestTemplatesResult{{
|
||||
Name: "slack.title",
|
||||
Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=folder title lbl1=val1 \nCommonAnnotations: ann1=annv1 \n",
|
||||
Text: "\nReceiver: TestReceiver\nStatus: firing\nExternalURL: http://localhost:9093\nAlerts: 1\nFiring Alerts: 1\nResolved Alerts: 0\nGroupLabels: group_label=group_label_value \nCommonLabels: alertname=alert1 grafana_folder=Test Folder lbl1=val1 \nCommonAnnotations: ann1=annv1 \n",
|
||||
Scope: alertingNotify.TemplateScope(apimodels.RootScope),
|
||||
}},
|
||||
Errors: nil,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
v2 "github.com/prometheus/alertmanager/api/v2"
|
||||
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
)
|
||||
@@ -30,13 +31,17 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei
|
||||
},
|
||||
})
|
||||
}
|
||||
var alert *alertingNotify.TestReceiversConfigAlertParams
|
||||
a := &alertingNotify.PostableAlert{}
|
||||
if c.Alert != nil {
|
||||
alert = &alertingNotify.TestReceiversConfigAlertParams{Annotations: c.Alert.Annotations, Labels: c.Alert.Labels}
|
||||
a.Annotations = v2.ModelLabelSetToAPILabelSet(c.Alert.Annotations)
|
||||
a.Labels = v2.ModelLabelSetToAPILabelSet(c.Alert.Labels)
|
||||
}
|
||||
|
||||
AddDefaultLabelsAndAnnotations(a)
|
||||
return am.Base.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{
|
||||
Alert: alert,
|
||||
Alert: &alertingNotify.TestReceiversConfigAlertParams{
|
||||
Annotations: v2.APILabelSetToModelLabelSet(a.Annotations),
|
||||
Labels: v2.APILabelSetToModelLabelSet(a.Labels),
|
||||
},
|
||||
Receivers: receivers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,10 +131,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -214,10 +219,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -315,10 +325,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -392,10 +407,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -480,10 +500,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -581,10 +606,15 @@ func TestIntegrationTestReceivers(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -687,10 +717,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
|
||||
"annotations": {
|
||||
"annotation1": "value1",
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana",
|
||||
"label1": "value1"
|
||||
}
|
||||
@@ -776,10 +811,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "This is a custom annotation",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "TestAlert",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
@@ -863,10 +903,15 @@ func TestIntegrationTestReceiversAlertCustomization(t *testing.T) {
|
||||
"alert": {
|
||||
"annotations": {
|
||||
"summary": "Notification test",
|
||||
"__value_string__": "[ metric='foo' labels={instance=bar} value=10 ]"
|
||||
"__dashboardUid__": "dashboard_uid",
|
||||
"__orgId__": "1",
|
||||
"__panelId__": "1",
|
||||
"__value_string__": "[ var='B' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=22 ], [ var='C' labels={__name__=go_threads, instance=host.docker.internal:3000, job=grafana} value=1 ]",
|
||||
"__values__": "{\"B\":22,\"C\":1}"
|
||||
},
|
||||
"labels": {
|
||||
"alertname": "This is a custom label",
|
||||
"grafana_folder": "Test Folder",
|
||||
"instance": "Grafana"
|
||||
}
|
||||
},
|
||||
|
||||
+19
-6
@@ -1,7 +1,7 @@
|
||||
import 'core-js/stable/structured-clone';
|
||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
import { render, screen, within } from 'test/test-utils';
|
||||
|
||||
import EditContactPoint from 'app/features/alerting/unified/components/contact-points/EditContactPoint';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -15,6 +15,20 @@ const Index = () => {
|
||||
return <div>redirected</div>;
|
||||
};
|
||||
|
||||
jest.mock('@grafana/ui', () => ({
|
||||
...jest.requireActual('@grafana/ui'),
|
||||
CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) {
|
||||
return <input data-testid="mockeditor" value={value} onChange={(e) => onBlur(e.currentTarget.value)} />;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'react-virtualized-auto-sizer',
|
||||
() =>
|
||||
({ children }: { children: ({ height, width }: { height: number; width: number }) => JSX.Element }) =>
|
||||
children({ height: 500, width: 400 })
|
||||
);
|
||||
|
||||
const renderEditContactPoint = (contactPointUid: string) =>
|
||||
render(
|
||||
<Routes>
|
||||
@@ -30,8 +44,7 @@ beforeEach(() => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
||||
});
|
||||
|
||||
const getTemplatePreviewContent = async () =>
|
||||
await screen.findByRole('presentation', { description: /Preview with the default payload/i });
|
||||
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).getByTestId('mockeditor');
|
||||
|
||||
const templatesSelectorTestId = 'existing-templates-selector';
|
||||
|
||||
@@ -44,11 +57,11 @@ describe('Edit contact point', () => {
|
||||
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);
|
||||
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`);
|
||||
|
||||
// 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);
|
||||
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "custom-email" . }}`);
|
||||
|
||||
// Close the drawer
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
@@ -62,7 +75,7 @@ describe('Edit contact point', () => {
|
||||
await user.click(screen.getByRole('radio', { name: /select notification template/i }));
|
||||
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'slack-template');
|
||||
|
||||
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
|
||||
expect(await getTemplatePreviewContent()).toHaveValue(`some example preview for {{ template "slack-template" . }}`);
|
||||
|
||||
// Close the drawer
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
|
||||
@@ -336,6 +336,7 @@ export const TemplateForm = ({ originalTemplate, prefill, alertmanager }: Props)
|
||||
<TemplatePreview
|
||||
payload={payload}
|
||||
templateName={watch('title')}
|
||||
templateContent={watch('content')}
|
||||
setPayloadFormatError={setPayloadFormatError}
|
||||
payloadFormatError={payloadFormatError}
|
||||
className={cx(styles.templatePreview, styles.minEditorSize)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { default as React } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, screen, waitFor } from 'test/test-utils';
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { Components } from '@grafana/e2e-selectors';
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
import { TemplateFormValues, defaults } from './TemplateForm';
|
||||
import { TemplatePreview } from './TemplatePreview';
|
||||
|
||||
jest.mock('@grafana/ui', () => ({
|
||||
...jest.requireActual('@grafana/ui'),
|
||||
CodeEditor: function CodeEditor({ value, onBlur }: { value: string; onBlur: (newValue: string) => void }) {
|
||||
return <input data-testid="mockeditor" value={value} onChange={(e) => onBlur(e.currentTarget.value)} />;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'react-virtualized-auto-sizer',
|
||||
() =>
|
||||
@@ -50,6 +57,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'bla bla bla'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||
setPayloadFormatError={jest.fn()}
|
||||
/>,
|
||||
@@ -66,6 +74,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'{"a":"b"}'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||
setPayloadFormatError={setError}
|
||||
/>,
|
||||
@@ -81,6 +90,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'potatos and cherries'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={'Unexpected token b in JSON at position 0'}
|
||||
setPayloadFormatError={jest.fn()}
|
||||
/>,
|
||||
@@ -100,6 +110,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'[{"a":"b"}]'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={null}
|
||||
setPayloadFormatError={jest.fn()}
|
||||
/>,
|
||||
@@ -123,6 +134,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'[{"a":"b"}]'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={null}
|
||||
setPayloadFormatError={jest.fn()}
|
||||
/>,
|
||||
@@ -133,8 +145,11 @@ describe('TemplatePreview component', () => {
|
||||
await waitFor(() => {
|
||||
expect(previews()).toHaveLength(2);
|
||||
});
|
||||
expect(previews()[0]).toHaveTextContent('This is the template result bla bla bla');
|
||||
expect(previews()[1]).toHaveTextContent('This is the template2 result bla bla bla');
|
||||
const previewItems = previews();
|
||||
expect(within(previewItems[0]).getByRole('banner')).toHaveTextContent('template1');
|
||||
expect(within(previewItems[0]).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla');
|
||||
expect(within(previewItems[1]).getByRole('banner')).toHaveTextContent('template2');
|
||||
expect(within(previewItems[1]).getByTestId('mockeditor')).toHaveValue('This is the template2 result bla bla bla');
|
||||
});
|
||||
|
||||
it('Should render preview response with some errors, if payload has correct format ', async () => {
|
||||
@@ -151,6 +166,7 @@ describe('TemplatePreview component', () => {
|
||||
<TemplatePreview
|
||||
payload={'[{"a":"b"}]'}
|
||||
templateName="potato"
|
||||
templateContent={`{{ define "potato" }}{{ . }}{{ end }}`}
|
||||
payloadFormatError={null}
|
||||
setPayloadFormatError={jest.fn()}
|
||||
/>,
|
||||
@@ -165,6 +181,6 @@ describe('TemplatePreview component', () => {
|
||||
expect(alerts()[1]).toHaveTextContent(/Unexpected "{" in operand/i);
|
||||
|
||||
const previewContent = screen.getByRole('listitem');
|
||||
expect(previewContent).toHaveTextContent('This is the template result bla bla bla');
|
||||
expect(within(previewContent).getByTestId('mockeditor')).toHaveValue('This is the template result bla bla bla');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { compact, uniqueId } from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Box, Button, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
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,
|
||||
templateName,
|
||||
templateContent,
|
||||
payloadFormatError,
|
||||
setPayloadFormatError,
|
||||
className,
|
||||
}: {
|
||||
payload: string;
|
||||
templateName: string;
|
||||
templateContent: string;
|
||||
payloadFormatError: string | null;
|
||||
setPayloadFormatError: (value: React.SetStateAction<string | null>) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { watch } = useFormContext<TemplateFormValues>();
|
||||
|
||||
const templateContent = watch('content');
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -74,13 +70,25 @@ function PreviewResultViewer({ previews }: { previews: TemplatePreviewResult[] }
|
||||
const singleTemplate = previews.length === 1;
|
||||
|
||||
return (
|
||||
<ul className={styles.viewer.container}>
|
||||
{previews.map((preview) => (
|
||||
<li className={styles.viewer.box} key={preview.name}>
|
||||
{singleTemplate ? null : <header className={styles.viewer.header}>{preview.name}</header>}
|
||||
<pre className={styles.viewer.pre}>{preview.text ?? '<Empty>'}</pre>
|
||||
</li>
|
||||
))}
|
||||
<ul className={styles.viewer.container} data-testid="template-preview">
|
||||
{previews.map((preview) => {
|
||||
return (
|
||||
<li className={styles.viewer.box} key={preview.name}>
|
||||
{singleTemplate ? null : <header className={styles.viewer.header}>{preview.name}</header>}
|
||||
<CodeEditor
|
||||
containerStyles={styles.editorContainer}
|
||||
language={'plaintext'}
|
||||
showLineNumbers={false}
|
||||
showMiniMap={false}
|
||||
value={preview.text}
|
||||
readOnly={true}
|
||||
monacoOptions={{
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +109,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
}),
|
||||
editorContainer: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}),
|
||||
viewerContainer: ({ height }: { height: number }) =>
|
||||
css({
|
||||
height,
|
||||
@@ -128,12 +141,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
errorText: css({
|
||||
color: theme.colors.error.text,
|
||||
}),
|
||||
pre: css({
|
||||
backgroundColor: 'transparent',
|
||||
margin: 0,
|
||||
border: 'none',
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,17 +49,16 @@ export function registerGoTemplateAutocomplete(monaco: Monaco): IDisposable {
|
||||
}
|
||||
|
||||
function isInsideGoExpression(model: editor.ITextModel, position: Position) {
|
||||
const searchRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: model.getLineMinColumn(position.lineNumber),
|
||||
endColumn: model.getLineMaxColumn(position.lineNumber),
|
||||
};
|
||||
// Need to trick findMatches into enabling multiline matches. One way to do this is to have \n in the regex.
|
||||
const goSyntaxRegex = '\\{\\{(?:.|\\n)+?\\}\\}';
|
||||
const matches = model.findMatches(goSyntaxRegex, model.getFullModelRange(), true, false, null, false);
|
||||
|
||||
const goSyntaxRegex = '\\{\\{[a-zA-Z0-9._() "]+\\}\\}';
|
||||
const matches = model.findMatches(goSyntaxRegex, searchRange, true, false, null, true);
|
||||
|
||||
return matches.some((match) => match.range.containsPosition(position));
|
||||
return matches.some((match) =>
|
||||
match.range.containsPosition({
|
||||
lineNumber: position.lineNumber,
|
||||
column: position.column + 1, // Stricter check to avoid matching on the closing bracket.
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export class CompletionProvider {
|
||||
|
||||
+20
-25
@@ -10,8 +10,9 @@ import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/d
|
||||
|
||||
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
|
||||
import { TemplateEditor } from '../../TemplateEditor';
|
||||
import { getPreviewResults } from '../../TemplatePreview';
|
||||
import { usePreviewTemplate } from '../../usePreviewTemplate';
|
||||
import { TemplatePreview } from '../../TemplatePreview';
|
||||
|
||||
import { getUseTemplateText } from './utils';
|
||||
|
||||
export function TemplateContentAndPreview({
|
||||
payload,
|
||||
@@ -33,11 +34,6 @@ export function TemplateContentAndPreview({
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const isGrafanaAlertManager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
|
||||
|
||||
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}>
|
||||
@@ -62,24 +58,15 @@ export function TemplateContentAndPreview({
|
||||
</div>
|
||||
|
||||
{isGrafanaAlertManager && (
|
||||
<div className={styles.container}>
|
||||
<EditorColumnHeader
|
||||
id={templatePreviewId}
|
||||
label={t(
|
||||
'alerting.template-content-and-preview.label-preview-with-the-default-payload',
|
||||
'Preview with the default payload'
|
||||
)}
|
||||
/>
|
||||
<Box flex={1}>
|
||||
<div
|
||||
role="presentation"
|
||||
aria-describedby={templatePreviewId}
|
||||
className={styles.viewerContainer({ height: 'minHeight' })}
|
||||
>
|
||||
{previewToRender}
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
<TemplatePreview
|
||||
payload={payload}
|
||||
// This should be an empty template name so that the test API treats it as a new unnamed template.
|
||||
templateName={''}
|
||||
templateContent={getUseTemplateText(templateName)}
|
||||
setPayloadFormatError={setPayloadFormatError}
|
||||
payloadFormatError={payloadFormatError}
|
||||
className={cx(styles.templatePreview, styles.minEditorSize)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -98,6 +85,14 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
}),
|
||||
templatePreview: css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
}),
|
||||
minEditorSize: css({
|
||||
minHeight: 300,
|
||||
minWidth: 300,
|
||||
}),
|
||||
viewerContainer: ({ height }: { height: number | string }) =>
|
||||
css({
|
||||
height,
|
||||
|
||||
+1
-3
@@ -220,9 +220,7 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
|
||||
/>
|
||||
<IconButton
|
||||
tooltip="Copy selected notification template to clipboard. You can use it in the custom tab."
|
||||
onClick={() =>
|
||||
copyToClipboard(getUseTemplateText(template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''))
|
||||
}
|
||||
onClick={() => copyToClipboard(template?.value?.content ?? defaultTemplateValue?.value?.content ?? '')}
|
||||
name="copy"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -142,7 +142,7 @@ const getGrafanaAlertmanagerTemplatePreview = () =>
|
||||
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({ results: [{ name: 'asdasd', text: `some example preview for ${body.template}` }] });
|
||||
}
|
||||
|
||||
return HttpResponse.json({});
|
||||
|
||||
@@ -1963,7 +1963,6 @@
|
||||
"tooltip-delete": "delete"
|
||||
},
|
||||
"template-content-and-preview": {
|
||||
"label-preview-with-the-default-payload": "Preview with the default payload",
|
||||
"label-template-content": "Template content"
|
||||
},
|
||||
"template-data-table": {
|
||||
|
||||
Reference in New Issue
Block a user