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
parent 4c8c243c6e
commit 151e57df70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 276 additions and 133 deletions

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(

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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 && (

View File

@ -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' }),
}, },

View File

@ -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) }}
/>
);
};

View File

@ -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 (

View File

@ -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();
});
});

View File

@ -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>

View File

@ -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)}
/> />

View File

@ -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>
); );

View File

@ -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} />
); );
} }

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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`
);
});
});
});

View File

@ -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);
}