Alerting: Fix template editing issues for contact points (#95387)
This commit is contained in:
@@ -41,9 +41,11 @@ generatedTemplatesApi.enhanceEndpoints({
|
||||
},
|
||||
});
|
||||
|
||||
export type TemplatesTestPayload = { template: string; alerts: AlertField[]; name: string };
|
||||
|
||||
export const templatesApi = generatedTemplatesApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
previewTemplate: build.mutation<TemplatePreviewResponse, { template: string; alerts: AlertField[]; name: string }>({
|
||||
previewTemplate: build.mutation<TemplatePreviewResponse, TemplatesTestPayload>({
|
||||
query: ({ template, alerts, name }) => ({
|
||||
url: previewTemplateUrl,
|
||||
data: { template: template, alerts: alerts, name: name },
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import 'core-js/stable/structured-clone';
|
||||
import { Routes, Route } from 'react-router-dom-v5-compat';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import EditContactPoint from 'app/features/alerting/unified/components/contact-points/EditContactPoint';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { setupMswServer } from '../../mockApi';
|
||||
import { grantUserPermissions } from '../../mocks';
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const Index = () => {
|
||||
return <div>redirected</div>;
|
||||
};
|
||||
|
||||
const renderEditContactPoint = (contactPointUid: string) =>
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/alerting/notifications" element={<Index />} />
|
||||
<Route path="/alerting/notifications/receivers/:name/edit" element={<EditContactPoint />} />
|
||||
</Routes>,
|
||||
{
|
||||
historyOptions: { initialEntries: [`/alerting/notifications/receivers/${contactPointUid}/edit`] },
|
||||
}
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
||||
});
|
||||
|
||||
const getTemplatePreviewContent = async () =>
|
||||
await screen.findByRole('presentation', { description: /Preview with the default payload/i });
|
||||
|
||||
const templatesSelectorTestId = 'existing-templates-selector';
|
||||
|
||||
describe('Edit contact point', () => {
|
||||
it('can edit a contact point with existing template field values', async () => {
|
||||
const { user } = renderEditContactPoint('lotsa-emails');
|
||||
|
||||
// Expand settings and open "edit message template" drawer
|
||||
await user.click(await screen.findByText(/optional email settings/i));
|
||||
await user.click(await screen.findByRole('button', { name: /edit message/i }));
|
||||
expect(await screen.findByRole('dialog', { name: /edit message/i })).toBeInTheDocument();
|
||||
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
|
||||
|
||||
// Change the preset template and check that the preview updates correctly
|
||||
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'custom-email');
|
||||
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for custom-email/i);
|
||||
|
||||
// Close the drawer
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
|
||||
// Check a setting that has an existing custom value, and change it to a preset template
|
||||
await user.click(await screen.findByRole('button', { name: /edit subject/i }));
|
||||
expect(await screen.findByRole('dialog', { name: /edit subject/i })).toBeInTheDocument();
|
||||
// If this isn't correct, then we haven't set the correct initial state for the radio buttons/tabs
|
||||
expect(await screen.findByLabelText(/custom template value/i)).toHaveValue('some custom value');
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /select existing template/i }));
|
||||
await clickSelectOption(screen.getByTestId(templatesSelectorTestId), 'slack-template');
|
||||
|
||||
expect(await getTemplatePreviewContent()).toHaveTextContent(/some example preview for slack-template/i);
|
||||
|
||||
// Close the drawer
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
|
||||
expect(await screen.findByText(/template: custom-email/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/template: slack-template/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save contact point/i }));
|
||||
|
||||
expect(await screen.findByText(/redirected/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+4
@@ -49,7 +49,9 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
|
||||
"message": "{{ template "slack-template" . }}",
|
||||
"singleEmail": false,
|
||||
"subject": "some custom value",
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
|
||||
@@ -243,7 +245,9 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
|
||||
"message": "{{ template "slack-template" . }}",
|
||||
"singleEmail": false,
|
||||
"subject": "some custom value",
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
|
||||
|
||||
+7
-3
@@ -4,12 +4,16 @@ import * as React from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Label, Stack, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export function EditorColumnHeader({ label, actions }: { label: string; actions?: React.ReactNode }) {
|
||||
type Props = { label: string; actions?: React.ReactNode; id?: string };
|
||||
|
||||
export function EditorColumnHeader({ label, actions, id }: Props) {
|
||||
const styles = useStyles2(editorColumnStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Label className={styles.label}>{label}</Label>
|
||||
<Label className={styles.label} id={id}>
|
||||
{label}
|
||||
</Label>
|
||||
<Stack direction="row" gap={1}>
|
||||
{actions}
|
||||
</Stack>
|
||||
@@ -17,7 +21,7 @@ export function EditorColumnHeader({ label, actions }: { label: string; actions?
|
||||
);
|
||||
}
|
||||
|
||||
export const editorColumnStyles = (theme: GrafanaTheme2) => ({
|
||||
const editorColumnStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
|
||||
+2
@@ -76,7 +76,9 @@ exports[`alerting API server disabled should be able to test and save a receiver
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com",
|
||||
"message": "{{ template "slack-template" . }}",
|
||||
"singleEmail": false,
|
||||
"subject": "some custom value",
|
||||
},
|
||||
"type": "email",
|
||||
"uid": "af306c96-35a2-4d6e-908a-4993e245dbb2",
|
||||
|
||||
+10
-2
@@ -35,6 +35,8 @@ export function TemplateContentAndPreview({
|
||||
const { data, error } = usePreviewTemplate(templateContent, templateName, payload, setPayloadFormatError);
|
||||
const previewToRender = getPreviewResults(error, payloadFormatError, data);
|
||||
|
||||
const templatePreviewId = 'template-preview';
|
||||
|
||||
return (
|
||||
<div className={cx(className, styles.mainContainer)}>
|
||||
<div className={styles.container}>
|
||||
@@ -58,9 +60,15 @@ export function TemplateContentAndPreview({
|
||||
|
||||
{isGrafanaAlertManager && (
|
||||
<div className={styles.container}>
|
||||
<EditorColumnHeader label="Preview with the default payload" />
|
||||
<EditorColumnHeader id={templatePreviewId} label="Preview with the default payload" />
|
||||
<Box flex={1}>
|
||||
<div className={styles.viewerContainer({ height: 'minHeight' })}>{previewToRender}</div>
|
||||
<div
|
||||
role="presentation"
|
||||
aria-describedby={templatePreviewId}
|
||||
className={styles.viewerContainer({ height: 'minHeight' })}
|
||||
>
|
||||
{previewToRender}
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+56
-69
@@ -8,7 +8,7 @@ import {
|
||||
Button,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Input,
|
||||
Label,
|
||||
RadioButtonGroup,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TextArea,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import {
|
||||
trackEditInputWithTemplate,
|
||||
trackUseCustomInputInTemplate,
|
||||
@@ -34,6 +35,8 @@ import { defaultPayloadString } from '../../TemplateForm';
|
||||
import { TemplateContentAndPreview } from './TemplateContentAndPreview';
|
||||
import { getTemplateName, getUseTemplateText, matchesOnlyOneTemplate, parseTemplates } from './utils';
|
||||
|
||||
const { useGetDefaultTemplatesQuery } = templatesApi;
|
||||
|
||||
interface TemplatesPickerProps {
|
||||
onSelect: (temnplate: string) => void;
|
||||
option: NotificationChannelOption;
|
||||
@@ -45,27 +48,23 @@ export function TemplatesPicker({ onSelect, option, valueInForm }: TemplatesPick
|
||||
setShowTemplates(true);
|
||||
trackEditInputWithTemplate();
|
||||
};
|
||||
const handleClose = () => setShowTemplates(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="edit"
|
||||
tooltip={'Edit using existing templates.'}
|
||||
tooltip={`Edit ${option.label.toLowerCase()} using existing templates.`}
|
||||
onClick={onClick}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label={'Select available template from the list of available templates.'}
|
||||
>
|
||||
{`Edit ${option.label}`}
|
||||
</Button>
|
||||
|
||||
{showTemplates && (
|
||||
<Drawer title={`Edit ${option.label}`} size="md" onClose={() => setShowTemplates(false)}>
|
||||
<TemplateSelector
|
||||
onSelect={onSelect}
|
||||
onClose={() => setShowTemplates(false)}
|
||||
option={option}
|
||||
valueInForm={valueInForm}
|
||||
/>
|
||||
<Drawer title={`Edit ${option.label}`} size="md" onClose={handleClose}>
|
||||
<TemplateSelector onSelect={onSelect} onClose={handleClose} option={option} valueInForm={valueInForm} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
@@ -102,10 +101,6 @@ export function getTemplateOptions(templateFiles: NotificationTemplate[], defaul
|
||||
// return the sum of default and custom templates
|
||||
return Array.from(templateMap.values());
|
||||
}
|
||||
function getContentFromOptions(name: string, options: Array<SelectableValue<Template>>) {
|
||||
const template = options.find((option) => option.label === name);
|
||||
return template?.value?.content ?? '';
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
name: string;
|
||||
@@ -117,40 +112,44 @@ interface TemplateSelectorProps {
|
||||
option: NotificationChannelOption;
|
||||
valueInForm: string;
|
||||
}
|
||||
|
||||
function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSelectorProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const useGetDefaultTemplatesQuery = templatesApi.endpoints.getDefaultTemplates.useQuery;
|
||||
const [template, setTemplate] = useState<Template | undefined>(undefined);
|
||||
const [inputToUpdate, setInputToUpdate] = useState<string>('');
|
||||
const [inputToUpdateCustom, setInputToUpdateCustom] = useState<string>(valueInForm);
|
||||
const valueInFormIsCustom = Boolean(valueInForm) && !matchesOnlyOneTemplate(valueInForm);
|
||||
const [template, setTemplate] = useState<SelectableValue<Template> | undefined>(undefined);
|
||||
const [customTemplateValue, setCustomTemplateValue] = useState<string>(valueInForm);
|
||||
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const { data = [], error, isLoading } = useNotificationTemplates({ alertmanager: selectedAlertmanager! });
|
||||
const { data: defaultTemplates } = useGetDefaultTemplatesQuery();
|
||||
const [templateOption, setTemplateOption] = useState<TemplateFieldOption>('Existing');
|
||||
const [templateOption, setTemplateOption] = useState<TemplateFieldOption | undefined>(
|
||||
valueInFormIsCustom ? 'Custom' : 'Existing'
|
||||
);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const templateOptions: Array<SelectableValue<TemplateFieldOption>> = [
|
||||
{
|
||||
label: 'Select existing template',
|
||||
ariaLabel: 'Select existing template',
|
||||
value: 'Existing',
|
||||
description: `Select a single template and preview it, or copy it to paste it in the custom tab. ${templateOption === 'Existing' ? 'Clicking Save will save your changes to the selected template.' : ''}`,
|
||||
},
|
||||
{
|
||||
label: `Enter custom ${option.label.toLowerCase()}`,
|
||||
ariaLabel: `Enter custom ${option.label.toLowerCase()}`,
|
||||
value: 'Custom',
|
||||
description: `Enter custom ${option.label.toLowerCase()}. ${templateOption === 'Custom' ? 'Clicking Save will save the custom value only.' : ''}`,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setInputToUpdate(getUseTemplateText(template.name));
|
||||
if (template?.value?.name) {
|
||||
setCustomTemplateValue(getUseTemplateText(template.value.name));
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
function onCustomTemplateChange(customInput: string) {
|
||||
setInputToUpdateCustom(customInput);
|
||||
setCustomTemplateValue(customInput);
|
||||
}
|
||||
|
||||
const onTemplateOptionChange = (option: TemplateFieldOption) => {
|
||||
@@ -164,21 +163,14 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
|
||||
return getTemplateOptions(data, defaultTemplates);
|
||||
}, [data, defaultTemplates, isLoading, error]);
|
||||
|
||||
// if we are using only one template, we should settemplate to that template
|
||||
useEffect(() => {
|
||||
if (Boolean(valueInForm)) {
|
||||
if (matchesOnlyOneTemplate(valueInForm)) {
|
||||
const name = getTemplateName(valueInForm);
|
||||
setTemplate({
|
||||
name,
|
||||
content: getContentFromOptions(name, options),
|
||||
});
|
||||
} else {
|
||||
// if it's empty we default to select existing template
|
||||
setTemplateOption('Custom');
|
||||
}
|
||||
const defaultTemplateValue = useMemo(() => {
|
||||
if (!options.length || !Boolean(valueInForm) || !matchesOnlyOneTemplate(valueInForm)) {
|
||||
return null;
|
||||
}
|
||||
}, [valueInForm, setTemplate, setTemplateOption, options]);
|
||||
const nameOfTemplateInForm = getTemplateName(valueInForm);
|
||||
|
||||
return options.find((option) => option.label === nameOfTemplateInForm) || null;
|
||||
}, [options, valueInForm]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error loading templates</div>;
|
||||
@@ -202,26 +194,29 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Select<Template>
|
||||
data-testid="existing-templates-selector"
|
||||
placeholder="Choose template"
|
||||
aria-label="Choose template"
|
||||
onChange={(value: SelectableValue<Template>, _) => {
|
||||
setTemplate(value?.value);
|
||||
setTemplate(value);
|
||||
}}
|
||||
options={options}
|
||||
width={50}
|
||||
value={template ? { label: template.name, value: template } : undefined}
|
||||
defaultValue={defaultTemplateValue}
|
||||
/>
|
||||
<IconButton
|
||||
tooltip="Copy selected template to clipboard. You can use it in the custom tab."
|
||||
onClick={() => copyToClipboard(getUseTemplateText(template?.name ?? ''))}
|
||||
onClick={() =>
|
||||
copyToClipboard(getUseTemplateText(template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''))
|
||||
}
|
||||
name="copy"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<TemplateContentAndPreview
|
||||
templateContent={template?.content ?? ''}
|
||||
templateContent={template?.value?.content ?? defaultTemplateValue?.value?.content ?? ''}
|
||||
payload={defaultPayloadString}
|
||||
templateName={template?.name ?? ''}
|
||||
templateName={template?.value?.name ?? defaultTemplateValue?.value?.name ?? ''}
|
||||
setPayloadFormatError={() => {}}
|
||||
className={cx(styles.templatePreview, styles.minEditorSize)}
|
||||
payloadFormatError={null}
|
||||
@@ -231,7 +226,7 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
|
||||
<OptionCustomfield
|
||||
option={option}
|
||||
onCustomTemplateChange={onCustomTemplateChange}
|
||||
initialValue={inputToUpdateCustom}
|
||||
initialValue={customTemplateValue}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -242,13 +237,15 @@ function TemplateSelector({ onSelect, onClose, option, valueInForm }: TemplateSe
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onSelect(templateOption === 'Custom' ? inputToUpdateCustom : inputToUpdate);
|
||||
onClose();
|
||||
if (templateOption === 'Custom') {
|
||||
trackUseCustomInputInTemplate();
|
||||
onSelect(customTemplateValue);
|
||||
} else {
|
||||
trackUseSingleTemplateInInput();
|
||||
const name = template?.value?.name ?? defaultTemplateValue?.value?.name ?? '';
|
||||
onSelect(getUseTemplateText(name));
|
||||
}
|
||||
return onClose();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
@@ -267,31 +264,21 @@ function OptionCustomfield({
|
||||
onCustomTemplateChange(customInput: string): void;
|
||||
initialValue: string;
|
||||
}) {
|
||||
switch (option.element) {
|
||||
case 'textarea':
|
||||
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;
|
||||
}
|
||||
const id = `custom-template-${option.label}`;
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Label htmlFor={id}>
|
||||
<Trans i18nKey="alerting.contact-points.custom-template-value">Custom template value</Trans>
|
||||
</Label>
|
||||
<TextArea
|
||||
id={id}
|
||||
label="Custom template"
|
||||
placeholder={option.placeholder}
|
||||
onChange={(e) => onCustomTemplateChange(e.currentTarget.value)}
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface WrapWithTemplateSelectionProps extends PropsWithChildren {
|
||||
|
||||
@@ -289,7 +289,7 @@ export const grafanaAlertNotifiersMock: NotifierDTO[] = [
|
||||
label: 'Message',
|
||||
description:
|
||||
'Optional message. You can use templates to customize this field. Using a custom message will replace the default message',
|
||||
placeholder: '',
|
||||
placeholder: '{{ template "default.message" . }}',
|
||||
propertyName: 'message',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
|
||||
+2
@@ -124,6 +124,8 @@ const grafanaAlertmanagerConfig: AlertManagerCortexConfig = {
|
||||
addresses:
|
||||
'gilles.demey+1@grafana.com, gilles.demey+2@grafana.com, gilles.demey+3@grafana.com, gilles.demey+4@grafana.com',
|
||||
singleEmail: false,
|
||||
message: '{{ template "slack-template" . }}',
|
||||
subject: 'some custom value',
|
||||
},
|
||||
secureFields: {},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { http, HttpResponse, JsonBodyType, StrictResponse } from 'msw';
|
||||
|
||||
import { TemplatesTestPayload } from 'app/features/alerting/unified/api/templateApi';
|
||||
import receiversMock from 'app/features/alerting/unified/components/contact-points/__mocks__/receivers.mock.json';
|
||||
import { MOCK_SILENCE_ID_EXISTING, mockAlertmanagerAlert } from 'app/features/alerting/unified/mocks';
|
||||
import { defaultGrafanaAlertingConfigurationStatusResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi';
|
||||
@@ -135,9 +136,17 @@ export const updateAlertmanagerConfigHandler = (responseOverride?: typeof ALERTM
|
||||
});
|
||||
|
||||
const getGrafanaAlertmanagerTemplatePreview = () =>
|
||||
http.post('/api/alertmanager/grafana/config/api/v1/templates/test', () =>
|
||||
// TODO: Scaffold out template preview response as needed by tests
|
||||
HttpResponse.json({})
|
||||
http.post<never, TemplatesTestPayload>(
|
||||
'/api/alertmanager/grafana/config/api/v1/templates/test',
|
||||
async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body?.template.startsWith('{{')) {
|
||||
return HttpResponse.json({ results: [{ name: 'asdasd', text: `some example preview for ${body.name}` }] });
|
||||
}
|
||||
|
||||
return HttpResponse.json({});
|
||||
}
|
||||
);
|
||||
|
||||
const getReceiversHandler = () =>
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"contact-point": "Contact Point",
|
||||
"contact-points": {
|
||||
"create": "Create contact point",
|
||||
"custom-template-value": "Custom template value",
|
||||
"delete-reasons": {
|
||||
"heading": "Contact point cannot be deleted for the following reasons:",
|
||||
"no-permissions": "You do not have the required permission to delete this contact point",
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"contact-point": "Cőʼnŧäčŧ Pőįʼnŧ",
|
||||
"contact-points": {
|
||||
"create": "Cřęäŧę čőʼnŧäčŧ pőįʼnŧ",
|
||||
"custom-template-value": "Cūşŧőm ŧęmpľäŧę väľūę",
|
||||
"delete-reasons": {
|
||||
"heading": "Cőʼnŧäčŧ pőįʼnŧ čäʼnʼnőŧ þę đęľęŧęđ ƒőř ŧĥę ƒőľľőŵįʼnģ řęäşőʼnş:",
|
||||
"no-permissions": "Ÿőū đő ʼnőŧ ĥävę ŧĥę řęqūįřęđ pęřmįşşįőʼn ŧő đęľęŧę ŧĥįş čőʼnŧäčŧ pőįʼnŧ",
|
||||
|
||||
Reference in New Issue
Block a user