Alerting: Add Copy action to templates table (#62135)

* Add duplicate action to templates table

Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>

* Move duplicate icon, and remove provenance when rendering TemplateForm

* Create generic generateCopiedName method to avoid duplication and use it also in the CloneRuleEditor

* Use 'Copy' for duplicating templates and cloning alert rules

* Improve updating the template content with new unique define values when copying

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>

* Fix typo

---------

Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com>
Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Sonia Aguilar
2023-02-01 14:14:10 +01:00
committed by GitHub
co-authored by Virginia Cepeda Konrad Lalik
parent 4c8c243c6e
commit 151e57df70
16 changed files with 276 additions and 133 deletions
+10
View File
@@ -203,6 +203,16 @@ const unifiedRoutes: RouteDescriptor[] = [
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
), ),
}, },
{
path: '/alerting/notifications/:type/:id/duplicate',
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{ {
path: '/alerting/notifications/:type', path: '/alerting/notifications/:type',
roles: evaluateAccess( roles: evaluateAccess(
@@ -10,11 +10,10 @@ import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime'; import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import 'whatwg-fetch'; import 'whatwg-fetch';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto'; import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
import { CloneRuleEditor, generateCopiedRuleTitle } from './CloneRuleEditor'; import { CloneRuleEditor } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks'; import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks';
import { mockSearchApiResponse } from './mocks/grafanaApi'; import { mockSearchApiResponse } from './mocks/grafanaApi';
@@ -200,85 +199,3 @@ describe('CloneRuleEditor', function () {
}); });
}); });
}); });
describe('generateCopiedRuleTitle', () => {
it('should generate copy name', () => {
const fileName = 'my file';
const expectedDuplicateName = 'my file (copy)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [],
},
} as unknown as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from original file', () => {
const fileName = 'my file';
const duplicatedName = 'my file (copy)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 2)';
const expectedDuplicateName = 'my file (copy 3)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file in gap', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 3)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [
{
grafana_alert: { title: fileName },
},
{ grafana_alert: { title: duplicatedName } },
],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
});
@@ -6,11 +6,12 @@ import { locationService } from '@grafana/runtime/src';
import { Alert, LoadingPlaceholder } from '@grafana/ui/src'; import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
import { useDispatch } from '../../../types'; import { useDispatch } from '../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting'; import { RuleIdentifier } from '../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../types/unified-alerting-dto'; import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { fetchEditableRuleAction } from './state/actions'; import { fetchEditableRuleAction } from './state/actions';
import { generateCopiedName } from './utils/duplicate';
import { rulerRuleToFormValues } from './utils/rule-form'; import { rulerRuleToFormValues } from './utils/rule-form';
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules'; import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
import { createUrl } from './utils/url'; import { createUrl } from './utils/url';
@@ -30,7 +31,10 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier
if (rule) { if (rule) {
const ruleClone = cloneDeep(rule); const ruleClone = cloneDeep(rule);
changeRuleName(ruleClone.rule, generateCopiedRuleTitle(ruleClone)); changeRuleName(
ruleClone.rule,
generateCopiedName(getRuleName(ruleClone.rule), ruleClone.group.rules.map(getRuleName))
);
const formPrefill = rulerRuleToFormValues(ruleClone); const formPrefill = rulerRuleToFormValues(ruleClone);
// Provisioned alert rules have provisioned alert group which cannot be used in UI // Provisioned alert rules have provisioned alert group which cannot be used in UI
@@ -51,28 +55,13 @@ export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier
return ( return (
<Alert <Alert
title="Cannot duplicate. The rule does not exist" title="Cannot copy the rule. The rule does not exist"
buttonContent="Go back to alert list" buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))} onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/> />
); );
} }
export function generateCopiedRuleTitle(originRuleWithLocation: RuleWithLocation): string {
const originName = getRuleName(originRuleWithLocation.rule);
const existingRulesNames = originRuleWithLocation.group.rules.map(getRuleName);
const nonDuplicateName = originName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
let newName = `${nonDuplicateName} (copy)`;
for (let i = 2; existingRulesNames.includes(newName); i++) {
newName = `${nonDuplicateName} (copy ${i})`;
}
return newName;
}
function changeRuleName(rule: RulerRuleDTO, newName: string) { function changeRuleName(rule: RulerRuleDTO, newName: string) {
if (isGrafanaRulerRule(rule)) { if (isGrafanaRulerRule(rule)) {
rule.grafana_alert.title = newName; rule.grafana_alert.title = newName;
@@ -3,9 +3,9 @@ import pluralize from 'pluralize';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Redirect, Route, RouteChildrenProps, Switch, useLocation, useParams } from 'react-router-dom'; import { Redirect, Route, RouteChildrenProps, Switch, useLocation, useParams } from 'react-router-dom';
import { NavModelItem, GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Alert, LoadingPlaceholder, withErrorBoundary, useStyles2, Icon } from '@grafana/ui'; import { Alert, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { ContactPointsState } from '../../../types'; import { ContactPointsState } from '../../../types';
@@ -16,6 +16,7 @@ import { AlertManagerPicker } from './components/AlertManagerPicker';
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning';
import { NoAlertManagerWarning } from './components/NoAlertManagerWarning'; import { NoAlertManagerWarning } from './components/NoAlertManagerWarning';
import { DuplicateTemplateView } from './components/receivers/DuplicateTemplateView';
import { EditReceiverView } from './components/receivers/EditReceiverView'; import { EditReceiverView } from './components/receivers/EditReceiverView';
import { EditTemplateView } from './components/receivers/EditTemplateView'; import { EditTemplateView } from './components/receivers/EditTemplateView';
import { GlobalConfigForm } from './components/receivers/GlobalConfigForm'; import { GlobalConfigForm } from './components/receivers/GlobalConfigForm';
@@ -143,6 +144,17 @@ const Receivers = () => {
<Route exact={true} path="/alerting/notifications/templates/new"> <Route exact={true} path="/alerting/notifications/templates/new">
<NewTemplateView config={config} alertManagerSourceName={alertManagerSourceName} /> <NewTemplateView config={config} alertManagerSourceName={alertManagerSourceName} />
</Route> </Route>
<Route exact={true} path="/alerting/notifications/templates/:name/duplicate">
{({ match }: RouteChildrenProps<{ name: string }>) =>
match?.params.name && (
<DuplicateTemplateView
alertManagerSourceName={alertManagerSourceName}
config={config}
templateName={decodeURIComponent(match?.params.name)}
/>
)
}
</Route>
<Route exact={true} path="/alerting/notifications/templates/:name/edit"> <Route exact={true} path="/alerting/notifications/templates/:name/edit">
{({ match }: RouteChildrenProps<{ name: string }>) => {({ match }: RouteChildrenProps<{ name: string }>) =>
match?.params.name && ( match?.params.name && (
@@ -61,7 +61,7 @@ const renderRuleViewer = () => {
const ui = { const ui = {
actionButtons: { actionButtons: {
edit: byRole('link', { name: /edit/i }), edit: byRole('link', { name: /edit/i }),
clone: byRole('link', { name: /clone/i }), clone: byRole('link', { name: /copy/i }),
delete: byRole('button', { name: /delete/i }), delete: byRole('button', { name: /delete/i }),
silence: byRole('link', { name: 'Silence' }), silence: byRole('link', { name: 'Silence' }),
}, },
@@ -0,0 +1,37 @@
import React, { FC } from 'react';
import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { generateCopiedName } from '../../utils/duplicate';
import { updateDefinesWithUniqueValue } from '../../utils/templates';
import { TemplateForm } from './TemplateForm';
interface Props {
templateName: string;
config: AlertManagerCortexConfig;
alertManagerSourceName: string;
}
export const DuplicateTemplateView: FC<Props> = ({ config, templateName, alertManagerSourceName }) => {
const template = config.template_files?.[templateName];
if (!template) {
return (
<Alert severity="error" title="Template not found">
Sorry, this template does not seem to exists.
</Alert>
);
}
const duplicatedName = generateCopiedName(templateName, Object.keys(config.template_files));
return (
<TemplateForm
alertManagerSourceName={alertManagerSourceName}
config={config}
existing={{ name: duplicatedName, content: updateDefinesWithUniqueValue(template) }}
/>
);
};
@@ -1,6 +1,6 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { InfoBox } from '@grafana/ui'; import { Alert } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { TemplateForm } from './TemplateForm'; import { TemplateForm } from './TemplateForm';
@@ -17,9 +17,9 @@ export const EditTemplateView: FC<Props> = ({ config, templateName, alertManager
if (!template) { if (!template) {
return ( return (
<InfoBox severity="error" title="Template not found"> <Alert severity="error" title="Template not found">
Sorry, this template does not seem to exit. Sorry, this template does not seem to exists.
</InfoBox> </Alert>
); );
} }
return ( return (
@@ -0,0 +1,78 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { TemplatesTable } from './TemplatesTable';
const defaultConfig: AlertManagerCortexConfig = {
template_files: {
template1: `{{ define "define1" }}`,
},
alertmanager_config: {
templates: ['template1'],
},
};
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv);
const renderWithProvider = () => {
const store = configureStore();
render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<TemplatesTable config={defaultConfig} alertManagerName={'potato'} />
</Router>
</Provider>
);
};
describe('TemplatesTable', () => {
beforeEach(() => {
jest.resetAllMocks();
contextSrvMock.hasAccess.mockImplementation(() => true);
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return permissions.includes(action as AccessControlAction);
});
});
it('Should render templates table with the correct rows', () => {
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).getByRole('cell', { name: /template1/i })).toBeInTheDocument();
});
it('Should render duplicate template button when having permissions', () => {
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).getByRole('cell', { name: /Copy/i })).toBeInTheDocument();
});
it('Should not render duplicate template button when not having write permissions', () => {
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
return permissions.includes(action as AccessControlAction);
});
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).queryByRole('cell', { name: /Copy/i })).not.toBeInTheDocument();
});
});
@@ -102,24 +102,35 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
/> />
)} )}
{!provenance && ( {!provenance && (
<Authorize actions={[permissions.update, permissions.delete]}> <Authorize actions={[permissions.update]}>
<Authorize actions={[permissions.update]}> <ActionIcon
<ActionIcon to={makeAMLink(
to={makeAMLink( `/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`, alertManagerName
alertManagerName )}
)} tooltip="edit template"
tooltip="edit template" icon="pen"
icon="pen" />
/> </Authorize>
</Authorize> )}
<Authorize actions={[permissions.delete]}> {contextSrv.hasPermission(permissions.create) && (
<ActionIcon <ActionIcon
onClick={() => setTemplateToDelete(name)} to={makeAMLink(
tooltip="delete template" `/alerting/notifications/templates/${encodeURIComponent(name)}/duplicate`,
icon="trash-alt" alertManagerName
/> )}
</Authorize> tooltip="Copy template"
icon="copy"
/>
)}
{!provenance && (
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => setTemplateToDelete(name)}
tooltip="delete template"
icon="trash-alt"
/>
</Authorize> </Authorize>
)} )}
</td> </td>
@@ -28,7 +28,7 @@ export const CloneRuleButton = React.forwardRef<HTMLAnchorElement, CloneRuleButt
return ( return (
<> <>
<LinkButton <LinkButton
title="Clone" title="Copy"
className={className} className={className}
size="sm" size="sm"
key="clone" key="clone"
@@ -43,19 +43,19 @@ export const CloneRuleButton = React.forwardRef<HTMLAnchorElement, CloneRuleButt
<ConfirmModal <ConfirmModal
isOpen={!!provRuleCloneUrl} isOpen={!!provRuleCloneUrl}
title="Clone provisioned rule" title="Copy provisioned alert rule"
body={ body={
<div> <div>
<p> <p>
The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule. The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule.
</p> </p>
<p> <p>
You will need to set a new alert group for the cloned rule because the original one has been provisioned You will need to set a new alert group for the copied rule because the original one has been provisioned
and cannot be used for rules created in the UI. and cannot be used for rules created in the UI.
</p> </p>
</div> </div>
} }
confirmText="Clone" confirmText="Copy"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)} onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onDismiss={() => setProvRuleCloneUrl(undefined)} onDismiss={() => setProvRuleCloneUrl(undefined)}
/> />
@@ -129,7 +129,7 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
} }
buttons.push( buttons.push(
<Tooltip placement="top" content="Clone"> <Tooltip placement="top" content="Copy">
<CloneRuleButton ruleIdentifier={identifier} isProvisioned={isProvisioned} className={style.button} /> <CloneRuleButton ruleIdentifier={identifier} isProvisioned={isProvisioned} className={style.button} />
</Tooltip> </Tooltip>
); );
@@ -219,7 +219,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (hasCreateRulePermission && !isFederated) { if (hasCreateRulePermission && !isFederated) {
rightButtons.push( rightButtons.push(
<CloneRuleButton key="clone" text="Clone" ruleIdentifier={identifier} isProvisioned={isProvisioned} /> <CloneRuleButton key="clone" text="Copy" ruleIdentifier={identifier} isProvisioned={isProvisioned} />
); );
} }
@@ -0,0 +1,34 @@
import { generateCopiedName } from './duplicate';
describe('generateCopiedName', () => {
it('should generate copy name', () => {
const fileName = 'my file';
const expectedDuplicateName = 'my file (copy)';
expect(generateCopiedName(fileName, [])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from original file', () => {
const fileName = 'my file';
const duplicatedName = 'my file (copy)';
const expectedDuplicateName = 'my file (copy 2)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 2)';
const expectedDuplicateName = 'my file (copy 3)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file in gap', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 3)';
const expectedDuplicateName = 'my file (copy 2)';
expect(generateCopiedName(fileName, [fileName, duplicatedName])).toEqual(expectedDuplicateName);
});
});
@@ -0,0 +1,11 @@
export function generateCopiedName(originalName: string, exisitingNames: string[]) {
const nonDuplicateName = originalName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
let newName = `${nonDuplicateName} (copy)`;
for (let i = 2; exisitingNames.includes(newName); i++) {
newName = `${nonDuplicateName} (copy ${i})`;
}
return newName;
}
@@ -0,0 +1,36 @@
import { updateDefinesWithUniqueValue } from './templates';
jest.mock('lodash', () => ({
...jest.requireActual('lodash'),
now: jest.fn().mockImplementation(() => 99),
}));
describe('updateDefinesWithUniqueValue method', () => {
describe('only onw define', () => {
it('Should update the define values with a unique new one', () => {
expect(updateDefinesWithUniqueValue(`{{ define "t" }}\n{{.Alerts.Firing}}\n{{ end }}`)).toEqual(
`{{ define "t_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}`
);
});
});
describe('more than one define in the template', () => {
it('Should update the define values with a unique new one ', () => {
expect(
updateDefinesWithUniqueValue(
`{{ define "t1" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
)
).toEqual(
`{{ define "t1_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
);
});
it('Should update the define values with a unique new one, special chars included in the value', () => {
expect(
updateDefinesWithUniqueValue(
`{{ define "t1 /^*;$@" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
)
).toEqual(
`{{ define "t1 /^*;$@_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t2_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n{{ define "t3_NEW_99" }}\n{{.Alerts.Firing}}\n{{ end }}\n`
);
});
});
});
@@ -1,3 +1,5 @@
import { now } from 'lodash';
export function ensureDefine(templateName: string, templateContent: string): string { export function ensureDefine(templateName: string, templateContent: string): string {
// notification template content must be wrapped in {{ define "name" }} tag, // notification template content must be wrapped in {{ define "name" }} tag,
// but this is not obvious because user also has to provide name separately in the form. // but this is not obvious because user also has to provide name separately in the form.
@@ -12,3 +14,9 @@ export function ensureDefine(templateName: string, templateContent: string): str
} }
return content; return content;
} }
export function updateDefinesWithUniqueValue(templateContent: string): string {
const getNewValue = (match_: string, originalDefineName: string) => {
return `{{ define "${originalDefineName}_NEW_${now()}" }}`;
};
return templateContent.replace(/\{\{\s*define\s*\"(?<defineName>.*)\"\s*\}\}/g, getNewValue);
}