Alerting: Rework rule editor layout (#103744)
This commit is contained in:
@@ -1020,7 +1020,122 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [
|
||||
<<<<<<< HEAD
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/rule-types/GrafanaManagedAlert.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiAlert.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiRecordingRule.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/AlertStateTag.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleListErrors.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/settings/VersionManager.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||
=======
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
>>>>>>> origin/main
|
||||
],
|
||||
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('RuleEditor grafana recording rules', () => {
|
||||
await user.type(await ui.inputs.metric.find(), 'metricName');
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
|
||||
const requests = await capture;
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
@@ -96,7 +96,7 @@ describe('RuleEditor grafana recording rules', () => {
|
||||
await user.type(await ui.inputs.name.find(), 'my great new rule');
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
expect(requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -43,9 +43,13 @@ export function DurationQuickPick({ selectedDuration, groupEvaluationInterval, o
|
||||
onSelect(duration);
|
||||
}}
|
||||
>
|
||||
{duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration}
|
||||
{stringifyPendingPeriod(duration)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function stringifyPendingPeriod(duration: string): string {
|
||||
return duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration;
|
||||
}
|
||||
|
||||
+46
-131
@@ -5,8 +5,7 @@ import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import { Alert, Button, ConfirmModal, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import { Alert, Button, Spinner, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
rulerRuleType,
|
||||
} from 'app/features/alerting/unified/utils/rules';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { RuleGroupIdentifier, RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import {
|
||||
@@ -41,10 +40,7 @@ import {
|
||||
RulerGroupUpdatedResponse,
|
||||
isGrafanaGroupUpdatedResponse,
|
||||
} from '../../../api/alertRuleModel';
|
||||
import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../../featureToggles';
|
||||
import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup';
|
||||
import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
|
||||
import { useReturnTo } from '../../../hooks/useReturnTo';
|
||||
import {
|
||||
defaultFormValuesForRuleType,
|
||||
formValuesFromExistingRule,
|
||||
@@ -63,9 +59,7 @@ import {
|
||||
formValuesToRulerGrafanaRuleDTO,
|
||||
formValuesToRulerRuleDTO,
|
||||
} from '../../../utils/rule-form';
|
||||
import * as ruleId from '../../../utils/rule-id';
|
||||
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id';
|
||||
import { createRelativeUrl } from '../../../utils/url';
|
||||
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
|
||||
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
|
||||
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
|
||||
import AnnotationsStep from '../AnnotationsStep';
|
||||
@@ -83,27 +77,21 @@ type Props = {
|
||||
isManualRestore?: boolean;
|
||||
};
|
||||
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
const alertingListViewV2 = shouldUseAlertingListViewV2();
|
||||
|
||||
export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const notifyApp = useAppNotification();
|
||||
const { redirectToDetailsPage } = useRedirectToDetailsPage();
|
||||
|
||||
const routeParams = useParams<{ type: string; id: string }>();
|
||||
const uidFromParams = routeParams.id;
|
||||
|
||||
const { redirectToDetailsPage } = useRedirectToDetailsPage(uidFromParams);
|
||||
const [showEditYaml, setShowEditYaml] = useState(false);
|
||||
|
||||
const [deleteRuleFromGroup] = useDeleteRuleFromGroup();
|
||||
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
|
||||
const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup();
|
||||
|
||||
const { returnTo } = useReturnTo();
|
||||
const routeParams = useParams<{ type: string; id: string }>();
|
||||
const ruleType = translateRouteParamToRuleType(routeParams.type);
|
||||
|
||||
const uidFromParams = routeParams.id || '';
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
|
||||
const defaultValues: RuleFormValues = useMemo(() => {
|
||||
// If we have an existing AND a prefill, then we're coming from the restore dialog
|
||||
// and we want to merge the two
|
||||
@@ -157,7 +145,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
};
|
||||
|
||||
// @todo why is error not propagated to form?
|
||||
const submit = async (values: RuleFormValues, exitOnSave: boolean) => {
|
||||
const submit = async (values: RuleFormValues): Promise<void> => {
|
||||
const { type, evaluateEvery } = values;
|
||||
|
||||
if (conditionErrorMsg !== '') {
|
||||
@@ -208,41 +196,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
);
|
||||
}
|
||||
|
||||
const { dataSourceName, namespaceName, groupName } = targetRuleGroupIdentifier;
|
||||
|
||||
// V2 list is based on eventually consistent Prometheus API.
|
||||
// When a new rule group is created it takes a while for the new rule group to be reflected in the V2 list.
|
||||
// To avoid user confusion we redirect to the details page which is driven by a strongly consistent Ruler API..
|
||||
if (alertingListViewV2) {
|
||||
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitOnSave) {
|
||||
const returnToUrl = returnTo || getReturnToUrl(targetRuleGroupIdentifier, ruleDefinition);
|
||||
|
||||
locationService.push(returnToUrl);
|
||||
return;
|
||||
} else {
|
||||
// we stay in the same page
|
||||
|
||||
// Cloud Ruler rules identifier changes on update due to containing rule name and hash components
|
||||
// After successful update we need to update the URL to avoid displaying 404 errors
|
||||
if (rulerRuleType.dataSource.rule(ruleDefinition)) {
|
||||
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition);
|
||||
locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async () => {
|
||||
if (existing) {
|
||||
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing);
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule);
|
||||
|
||||
await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier);
|
||||
locationService.replace(returnTo ?? '/alerting/list');
|
||||
}
|
||||
redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult);
|
||||
return;
|
||||
};
|
||||
|
||||
const onInvalid: SubmitErrorHandler<RuleFormValues> = (errors): void => {
|
||||
@@ -266,49 +221,14 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
locationService.getHistory().goBack();
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
<Stack justifyContent="flex-end" alignItems="center">
|
||||
<Button
|
||||
data-testid="save-rule-and-exit"
|
||||
variant="primary"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit((values) => submit(values, true), onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.save-exit">Save rule and exit</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation} size="sm">
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
{existing ? (
|
||||
<Button fill="outline" variant="destructive" type="button" onClick={() => setShowDeleteModal(true)} size="sm">
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.delete">Delete</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
{existing && isCortexLokiOrRecordingRule(watch) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => setShowEditYaml(true)}
|
||||
disabled={isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.edit-yaml">Edit YAML</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const isPaused = rulerRuleType.grafana.alertingRule(existing?.rule) && isPausedRule(existing?.rule);
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPaused = rulerRuleType.grafana.rule(existing?.rule) && isPausedRule(existing?.rule);
|
||||
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<AppChromeUpdate actions={actionButtons} />
|
||||
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
|
||||
<div className={styles.contentOuter}>
|
||||
{isManualRestore && (
|
||||
@@ -348,39 +268,55 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
{!isRecordingRuleByType(type) && <AnnotationsStep />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* actions */}
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Button
|
||||
data-testid="save-rule"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleSubmit((values) => submit(values), onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
icon={isSubmitting ? 'spinner' : undefined}
|
||||
>
|
||||
{isSubmitting && <Spinner className={styles.buttonSpinner} inline={true} />}
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.save">Save</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" disabled={isSubmitting} type="button" onClick={cancelRuleCreation}>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{existing && isCortexLokiOrRecordingRule(watch) && (
|
||||
<Button variant="secondary" type="button" onClick={() => setShowEditYaml(true)} disabled={isSubmitting}>
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.edit-yaml">Edit YAML</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
{showDeleteModal ? (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title={t('alerting.alert-rule-form.title-delete-rule', 'Delete rule')}
|
||||
body="Deleting this rule will permanently remove it. Are you sure you want to delete this rule?"
|
||||
confirmText="Yes, delete"
|
||||
icon="exclamation-triangle"
|
||||
onConfirm={deleteRule}
|
||||
onDismiss={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showEditYaml && (
|
||||
<>
|
||||
{isGrafanaManagedRuleByType(type) && (
|
||||
{grafanaTypeRule && uidFromParams && (
|
||||
<GrafanaRuleExporter alertUid={uidFromParams} onClose={() => setShowEditYaml(false)} />
|
||||
)}
|
||||
|
||||
{!isGrafanaManagedRuleByType(type) && <RuleInspector onClose={() => setShowEditYaml(false)} />}
|
||||
{!grafanaTypeRule && <RuleInspector onClose={() => setShowEditYaml(false)} />}
|
||||
</>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function useRedirectToDetailsPage() {
|
||||
function useRedirectToDetailsPage(existingUid?: string) {
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const redirectGrafanaRule = useCallback(
|
||||
(saveResult: GrafanaGroupUpdatedResponse) => {
|
||||
const newOrUpdatedRuleUid = saveResult.created?.at(0) || saveResult.updated?.at(0);
|
||||
// if the response contains no created or updated rules, we'll use the existing UID.
|
||||
const newOrUpdatedRuleUid = (saveResult.created?.at(0) || saveResult.updated?.at(0)) ?? existingUid;
|
||||
if (newOrUpdatedRuleUid) {
|
||||
locationService.replace(
|
||||
rulesNav.detailsPageLink('grafana', { uid: newOrUpdatedRuleUid, ruleSourceName: 'grafana' })
|
||||
@@ -393,7 +329,7 @@ function useRedirectToDetailsPage() {
|
||||
logWarning('Cannot navigate to the new rule details page. The rule was created but the UID is missing.');
|
||||
}
|
||||
},
|
||||
[notifyApp]
|
||||
[existingUid, notifyApp]
|
||||
);
|
||||
|
||||
const redirectCloudRulerRule = useCallback((rule: RulerRuleDTO, groupId: RuleGroupIdentifier) => {
|
||||
@@ -427,27 +363,6 @@ function useRedirectToDetailsPage() {
|
||||
return { redirectToDetailsPage };
|
||||
}
|
||||
|
||||
function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) {
|
||||
const { dataSourceName, namespaceName, groupName } = groupId;
|
||||
|
||||
if (prometheusRulesPrimary && rulerRuleType.dataSource.rule(rule)) {
|
||||
const ruleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule);
|
||||
return createViewLinkFromIdentifier(ruleIdentifier);
|
||||
}
|
||||
|
||||
// TODO We could add namespace and group filters but for GMA the namespace = uid which doesn't work with the filters
|
||||
return '/alerting/list';
|
||||
}
|
||||
|
||||
// The result of this function is passed to locationService.push()
|
||||
// Hence it cannot contain the subpath prefix, so we cannot use createRelativeUrl for it
|
||||
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
const isCortexLokiOrRecordingRule = (watch: UseFormWatch<RuleFormValues>) => {
|
||||
const [ruleType, dataSourceName] = watch(['type', 'dataSourceName']);
|
||||
|
||||
|
||||
+29
-30
@@ -6,7 +6,6 @@ import { Button, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
|
||||
import {
|
||||
PostableRulerRuleGroupDTO,
|
||||
RulerRuleDTO,
|
||||
@@ -82,38 +81,38 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
||||
setExportData(undefined);
|
||||
}, [setExportData]);
|
||||
|
||||
const actionButtons = [
|
||||
<LinkButton href={returnTo} key="cancel" size="sm" variant="secondary" onClick={() => submit(undefined)}>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</LinkButton>,
|
||||
<Button key="export-rule" size="sm" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
|
||||
<Trans i18nKey="alerting.modify-export-rule-form.action-buttons.export">Export</Trans>
|
||||
</Button>,
|
||||
];
|
||||
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<AppChromeUpdate actions={actionButtons} />
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 */}
|
||||
<AlertRuleNameAndMetric />
|
||||
{/* Step 2 */}
|
||||
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} mode="draft" />
|
||||
{/* Step 3-4-5 */}
|
||||
<GrafanaFolderAndLabelsStep />
|
||||
<Stack direction="column">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 */}
|
||||
<AlertRuleNameAndMetric />
|
||||
{/* Step 2 */}
|
||||
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} mode="draft" />
|
||||
{/* Step 3-4-5 */}
|
||||
<GrafanaFolderAndLabelsStep />
|
||||
|
||||
{/* Step 4 & 5 */}
|
||||
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={true} />
|
||||
{/* Notifications step*/}
|
||||
<NotificationsStep alertUid={alertUid} />
|
||||
{/* Annotations only for cloud and Grafana */}
|
||||
<AnnotationsStep />
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
{exportData && <GrafanaRuleDesignExporter exportValues={exportData} onClose={onClose} uid={alertUid} />}
|
||||
{/* Step 4 & 5 */}
|
||||
<GrafanaEvaluationBehaviorStep existing={Boolean(existing)} enableProvisionedGroups={true} />
|
||||
{/* Notifications step*/}
|
||||
<NotificationsStep alertUid={alertUid} />
|
||||
{/* Annotations only for cloud and Grafana */}
|
||||
<AnnotationsStep />
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
{exportData && <GrafanaRuleDesignExporter exportValues={exportData} onClose={onClose} uid={alertUid} />}
|
||||
<Stack direction="row">
|
||||
<Button key="export-rule" onClick={formAPI.handleSubmit((formValues) => submit(formValues), onInvalid)}>
|
||||
<Trans i18nKey="alerting.modify-export-rule-form.action-buttons.export">Export</Trans>
|
||||
</Button>
|
||||
<LinkButton href={returnTo} key="cancel" variant="secondary" onClick={() => submit(undefined)}>
|
||||
<Trans i18nKey="alerting.common.cancel">Cancel</Trans>
|
||||
</LinkButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -97,7 +97,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
|
||||
// do not select a contact point
|
||||
// save and check that call to backend was not made
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
expect(await screen.findByText('Contact point is required.')).toBeInTheDocument();
|
||||
const capturedRequests = await capture;
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await selectContactPoint(user, contactPointName);
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
@@ -162,7 +162,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await selectContactPoint(user, contactPointName);
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
@@ -180,7 +180,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await selectFolderAndGroup(user);
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
@@ -198,7 +198,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()); // switch notifications step to advanced mode
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
@@ -218,7 +218,7 @@ describe('Can create a new grafana managed alert using simplified routing', () =
|
||||
await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
expect(serializedRequests).toMatchSnapshot();
|
||||
|
||||
@@ -275,16 +275,17 @@ interface TitleProps {
|
||||
health?: RuleHealth;
|
||||
ruleType?: PromRuleType;
|
||||
ruleOrigin?: RulePluginOrigin;
|
||||
returnToHref?: string;
|
||||
}
|
||||
|
||||
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
|
||||
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin, returnToHref = '' }: TitleProps) => {
|
||||
const isRecordingRule = ruleType === PromRuleType.Recording;
|
||||
|
||||
const { returnTo } = useReturnTo('/alerting/list');
|
||||
const { returnTo } = useReturnTo(returnToHref);
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
|
||||
<LinkButton variant="secondary" icon="angle-left" href={returnTo} />
|
||||
{returnToHref && <LinkButton variant="secondary" icon="angle-left" href={returnTo} />}
|
||||
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} size="lg" />}
|
||||
<Text variant="h1" truncate>
|
||||
{name}
|
||||
|
||||
@@ -70,7 +70,7 @@ interface EvaluationBehaviorSummaryProps {
|
||||
}
|
||||
|
||||
const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => {
|
||||
const every = rule.group.interval;
|
||||
const interval = rule.group.interval;
|
||||
const lastEvaluation = rule.promRule?.lastEvaluation;
|
||||
const lastEvaluationDuration = rule.promRule?.evaluationTime;
|
||||
const metric = rulerRuleType.grafana.recordingRule(rule.rulerRule)
|
||||
@@ -87,10 +87,10 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
|
||||
{metric}
|
||||
</DetailsField>
|
||||
)}
|
||||
{every && (
|
||||
{interval && (
|
||||
<DetailsField label={t('alerting.evaluation-behavior-summary.label-evaluate', 'Evaluate')} horizontal={true}>
|
||||
<Trans i18nKey="alerting.evaluation-behavior-summary.evaluate" values={{ every }}>
|
||||
Every {{ every }}
|
||||
<Trans i18nKey="alerting.evaluation-behavior-summary.evaluate" values={{ interval }}>
|
||||
Every {{ interval }}
|
||||
</Trans>
|
||||
</DetailsField>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
import { getWrapper, render, waitFor, within } from 'test/test-utils';
|
||||
import { byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
import { DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import {
|
||||
@@ -18,19 +19,22 @@ import { setupMswServer } from '../mockApi';
|
||||
import {
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
mockFolder,
|
||||
mockRulerAlertingRule,
|
||||
mockRulerGrafanaRule,
|
||||
mockRulerRuleGroup,
|
||||
} from '../mocks';
|
||||
import { grafanaRulerRule } from '../mocks/grafanaRulerApi';
|
||||
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi';
|
||||
import { setFolderResponse } from '../mocks/server/configure';
|
||||
import { AlertingQueryRunner } from '../state/AlertingQueryRunner';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { Annotation } from '../utils/constants';
|
||||
import { hashRulerRule } from '../utils/rule-id';
|
||||
|
||||
import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor';
|
||||
import { ExistingRuleEditor } from './ExistingRuleEditor';
|
||||
import { cloneRuleDefinition } from './clone.utils';
|
||||
import { getDefaultFormValues } from './formDefaults';
|
||||
|
||||
jest.mock('../components/rule-editor/ExpressionEditor', () => ({
|
||||
@@ -54,7 +58,6 @@ const ui = {
|
||||
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
|
||||
labelValue: (idx: number) => byTestId(`label-value-${idx}`),
|
||||
},
|
||||
loadingIndicator: byText('Loading the rule...'),
|
||||
};
|
||||
|
||||
const Providers = getWrapper({ renderWithRouter: true });
|
||||
@@ -68,18 +71,50 @@ function Wrapper({ children }: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
describe('CloneRuleEditor', function () {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
const folder = {
|
||||
title: 'Folder A',
|
||||
uid: grafanaRulerRule.grafana_alert.namespace_uid,
|
||||
id: 1,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
accessControl: {
|
||||
[AccessControlAction.AlertingRuleUpdate]: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.DataSourcesRead,
|
||||
AccessControlAction.FoldersRead,
|
||||
AccessControlAction.AlertingRuleExternalRead,
|
||||
AccessControlAction.AlertingRuleExternalWrite,
|
||||
]);
|
||||
|
||||
const dataSources = {
|
||||
default: mockDataSource({
|
||||
uid: MIMIR_DATASOURCE_UID,
|
||||
type: 'prometheus',
|
||||
name: 'Mimir',
|
||||
isDefault: true,
|
||||
}),
|
||||
};
|
||||
setupDataSources(dataSources.default);
|
||||
setFolderResponse(mockFolder(folder));
|
||||
});
|
||||
|
||||
describe('Grafana-managed rules', function () {
|
||||
it('should populate form values from the existing alert rule', async function () {
|
||||
setupDataSources();
|
||||
|
||||
render(
|
||||
<CloneRuleEditor sourceRuleId={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }} />,
|
||||
<ExistingRuleEditor
|
||||
identifier={{ uid: grafanaRulerRule.grafana_alert.uid, ruleSourceName: 'grafana' }}
|
||||
clone={true}
|
||||
/>,
|
||||
{ wrapper: Wrapper }
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(ui.loadingIndicator.query());
|
||||
await waitFor(() => {
|
||||
expect(within(ui.inputs.group.get()).queryByTestId('Spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -130,20 +165,19 @@ describe('CloneRuleEditor', function () {
|
||||
});
|
||||
|
||||
render(
|
||||
<CloneRuleEditor
|
||||
sourceRuleId={{
|
||||
<ExistingRuleEditor
|
||||
identifier={{
|
||||
ruleSourceName: 'my-prom-ds',
|
||||
namespace: 'namespace-one',
|
||||
groupName: 'group1',
|
||||
ruleName: 'First Ruler Rule',
|
||||
rulerRuleHash: hashRulerRule(originRule),
|
||||
}}
|
||||
clone={true}
|
||||
/>,
|
||||
{ wrapper: Wrapper }
|
||||
);
|
||||
|
||||
await waitForElementToBeRemoved(ui.loadingIndicator.query());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
|
||||
});
|
||||
|
||||
@@ -1,76 +1,133 @@
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { Alert, Stack } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertWarning } from '../AlertWarning';
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm';
|
||||
import { FederatedRuleWarning } from '../components/rule-viewer/FederatedRuleWarning';
|
||||
import { useRuleWithLocation } from '../hooks/useCombinedRule';
|
||||
import { useIsRuleEditable } from '../hooks/useIsRuleEditable';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import { Annotation } from '../utils/constants';
|
||||
import { stringifyErrorLike } from '../utils/misc';
|
||||
import { rulerRuleToFormValues } from '../utils/rule-form';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
import { isFederatedRuleGroup, rulerRuleType } from '../utils/rules';
|
||||
|
||||
import { defaultPageNav } from './RuleEditor';
|
||||
import { cloneRuleDefinition } from './clone.utils';
|
||||
interface ExistingRuleEditorProps {
|
||||
identifier: RuleIdentifier;
|
||||
/** Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values */
|
||||
// Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values
|
||||
prefill?: Partial<RuleFormValues>;
|
||||
// indicate if this is a manual restore
|
||||
isManualRestore?: boolean;
|
||||
// indicate if this is a cloning operation
|
||||
clone?: boolean;
|
||||
}
|
||||
|
||||
export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorProps) {
|
||||
const [queryParams] = useQueryParams();
|
||||
const isManualRestore = Boolean(queryParams.isManualRestore);
|
||||
|
||||
export function ExistingRuleEditor({
|
||||
identifier,
|
||||
prefill,
|
||||
isManualRestore = false,
|
||||
clone = false,
|
||||
}: ExistingRuleEditorProps) {
|
||||
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
|
||||
const {
|
||||
loading: loadingAlertRule,
|
||||
result: ruleWithLocation,
|
||||
error: fetchRuleError,
|
||||
} = useRuleWithLocation({ ruleIdentifier: identifier });
|
||||
|
||||
const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier);
|
||||
const {
|
||||
isEditable,
|
||||
loading: loadingEditable,
|
||||
error: errorEditable,
|
||||
} = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule);
|
||||
|
||||
// error handling for fetching rule and rule RBAC
|
||||
if (fetchRuleError || errorEditable) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('alerting.existing-rule-editor.title-failed-to-load-rule', 'Failed to load rule')}
|
||||
>
|
||||
{stringifyErrorLike(errorEditable ?? fetchRuleError)}
|
||||
</Alert>
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('alerting.existing-rule-editor.title-failed-to-load-rule', 'Failed to load rule')}
|
||||
>
|
||||
{stringifyErrorLike(errorEditable ?? fetchRuleError)}
|
||||
</Alert>
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const loading = loadingAlertRule || loadingEditable;
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder text={t('alerting.existing-rule-editor.text-loading-rule', 'Loading rule...')} />;
|
||||
return (
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()} isLoading={true}>
|
||||
{null}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ruleWithLocation && !loading) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.existing-rule-editor.title-rule-not-found', 'Rule not found')}>
|
||||
<Trans i18nKey="alerting.existing-rule-editor.sorry-this-rule-does-not-exist">
|
||||
Sorry! This rule does not exist.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
|
||||
<AlertWarning title={t('alerting.existing-rule-editor.title-rule-not-found', 'Rule not found')}>
|
||||
<Trans i18nKey="alerting.existing-rule-editor.sorry-this-rule-does-not-exist">
|
||||
Sorry! This rule does not exist.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditable === false) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.existing-rule-editor.title-cannot-edit-rule', 'Cannot edit rule')}>
|
||||
<Trans i18nKey="alerting.existing-rule-editor.sorry-permission">
|
||||
Sorry! You do not have permission to edit this rule.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav()}>
|
||||
<AlertWarning title={t('alerting.existing-rule-editor.title-cannot-edit-rule', 'Cannot edit rule')}>
|
||||
<Trans i18nKey="alerting.existing-rule-editor.sorry-permission">
|
||||
Sorry! You do not have permission to edit this rule.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <AlertRuleForm existing={ruleWithLocation} prefill={prefill} isManualRestore={isManualRestore} />;
|
||||
// we shouldn't get here because loading / error handling happens before this
|
||||
if (!ruleWithLocation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rulerRule = ruleWithLocation.rule;
|
||||
const summary = rulerRuleType.any.alertingRule(rulerRule) ? rulerRule.annotations?.[Annotation.summary] : null;
|
||||
|
||||
const isFederatedRule = isFederatedRuleGroup(ruleWithLocation.group);
|
||||
const isRecordingRule = rulerRuleType.any.recordingRule(rulerRule);
|
||||
|
||||
const pageTitle = isRecordingRule
|
||||
? t('alerting.editor.edit-recording-rule', 'Edit recording rule')
|
||||
: t('alerting.editor.edit-alert-rule', 'Edit alert rule');
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
navId="alert-list"
|
||||
subTitle={
|
||||
<Stack direction="column">
|
||||
{summary}
|
||||
{/* alerts and notifications and stuff */}
|
||||
{isFederatedRule && <FederatedRuleWarning />}
|
||||
</Stack>
|
||||
}
|
||||
pageNav={getPageNav({ text: pageTitle })}
|
||||
>
|
||||
{clone ? (
|
||||
<AlertRuleForm prefill={rulerRuleToFormValues(cloneRuleDefinition(ruleWithLocation))} />
|
||||
) : (
|
||||
<AlertRuleForm existing={ruleWithLocation} prefill={prefill} isManualRestore={isManualRestore} />
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const getPageNav = (pageNavOptions?: Partial<NavModelItem>): NavModelItem => {
|
||||
return { ...defaultPageNav, id: 'alert-rule-edit', text: '', ...pageNavOptions };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertWarning } from '../AlertWarning';
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
@@ -13,91 +11,98 @@ import { useRulesAccess } from '../utils/accessControlHooks';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
import { withPageErrorBoundary } from '../withPageErrorBoundary';
|
||||
|
||||
import { CloneRuleEditor } from './CloneRuleEditor';
|
||||
import { ExistingRuleEditor } from './ExistingRuleEditor';
|
||||
import { formValuesFromQueryParams, translateRouteParamToRuleType } from './formDefaults';
|
||||
|
||||
type RuleEditorPathParams = {
|
||||
export type RuleEditorPathParams = {
|
||||
id?: string;
|
||||
type?: 'recording' | 'alerting' | 'grafana-recording';
|
||||
};
|
||||
|
||||
const defaultPageNav: Partial<NavModelItem> = {
|
||||
icon: 'bell',
|
||||
export const defaultPageNav: Partial<NavModelItem> = {
|
||||
id: 'alert-rule-view',
|
||||
};
|
||||
|
||||
// sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL
|
||||
const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['type']) => {
|
||||
if (type === 'recording' || type === 'grafana-recording') {
|
||||
if (identifier) {
|
||||
// this branch should never trigger actually, the type param isn't used when editing rules
|
||||
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' };
|
||||
} else {
|
||||
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New recording rule' };
|
||||
}
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
// keep this one ambiguous, don't mentiond a specific alert type here
|
||||
return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' };
|
||||
} else {
|
||||
return { ...defaultPageNav, id: 'alert-rule-add', text: 'New alert rule' };
|
||||
}
|
||||
};
|
||||
|
||||
const RuleEditor = () => {
|
||||
const { identifier, type } = useRuleEditorPathParams();
|
||||
const { copyFromIdentifier, queryDefaults, isManualRestore } = useRuleEditorQueryParams();
|
||||
const { identifier } = useRuleEditorPathParams();
|
||||
const cloneIdentifier = useIdentifierFromCopy();
|
||||
const isManualRestore = useManualRestore();
|
||||
|
||||
const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
|
||||
|
||||
const getContent = useCallback(() => {
|
||||
if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-create-rules', 'Cannot create rules')}>
|
||||
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-create-rules">
|
||||
Sorry! You are not allowed to create rules.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
);
|
||||
}
|
||||
if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-create-rules', 'Cannot create rules')}>
|
||||
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-create-rules">
|
||||
Sorry! You are not allowed to create rules.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
);
|
||||
}
|
||||
|
||||
if (identifier && !canEditRules(identifier.ruleSourceName)) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-edit-rules', 'Cannot edit rules')}>
|
||||
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-rules">
|
||||
Sorry! You are not allowed to edit rules.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
);
|
||||
}
|
||||
if (identifier && !canEditRules(identifier.ruleSourceName)) {
|
||||
return (
|
||||
<AlertWarning title={t('alerting.rule-editor.get-content.title-cannot-edit-rules', 'Cannot edit rules')}>
|
||||
<Trans i18nKey="alerting.rule-editor.get-content.sorry-allowed-rules">
|
||||
Sorry! You are not allowed to edit rules.
|
||||
</Trans>
|
||||
</AlertWarning>
|
||||
);
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
return <ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} prefill={queryDefaults} />;
|
||||
}
|
||||
if (identifier) {
|
||||
return (
|
||||
<ExistingRuleEditor key={JSON.stringify(identifier)} identifier={identifier} isManualRestore={isManualRestore} />
|
||||
);
|
||||
}
|
||||
|
||||
if (copyFromIdentifier) {
|
||||
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
|
||||
}
|
||||
// new alert rule
|
||||
return <AlertRuleForm prefill={queryDefaults} isManualRestore={isManualRestore} />;
|
||||
}, [
|
||||
canCreateCloudRules,
|
||||
canCreateGrafanaRules,
|
||||
canEditRules,
|
||||
copyFromIdentifier,
|
||||
identifier,
|
||||
queryDefaults,
|
||||
isManualRestore,
|
||||
]);
|
||||
if (cloneIdentifier) {
|
||||
return (
|
||||
<ExistingRuleEditor
|
||||
key={JSON.stringify(identifier)}
|
||||
identifier={cloneIdentifier}
|
||||
clone={true}
|
||||
isManualRestore={isManualRestore}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// for new alerting or recording rules
|
||||
return <NewRuleEditor />;
|
||||
};
|
||||
|
||||
export const RECORDING_TYPE = ['grafana-recording', 'recording'];
|
||||
|
||||
/**
|
||||
* This one is used for creating new rules (both alerting and recording rules)
|
||||
*/
|
||||
function NewRuleEditor() {
|
||||
const prefill = useDefaultsFromQuery();
|
||||
const isManualRestore = useManualRestore();
|
||||
const { type = '', identifier = '' } = useRuleEditorPathParams();
|
||||
|
||||
const isExisting = Boolean(identifier);
|
||||
const isRecordingRule = RECORDING_TYPE.includes(type);
|
||||
|
||||
const newText = isRecordingRule
|
||||
? t('alerting.editor.new-recording-rule', 'New recording rule')
|
||||
: t('alerting.editor.new-alert-rule', 'New alert rule');
|
||||
|
||||
const editText = isRecordingRule
|
||||
? t('alerting.editor.edit-recording-rule', 'Edit recording rule')
|
||||
: t('alerting.editor.edit-alert-rule', 'Edit alert rule');
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper navId="alert-list" pageNav={getPageNav(identifier, type)}>
|
||||
{getContent()}
|
||||
<AlertingPageWrapper
|
||||
navId="alert-list"
|
||||
pageNav={{
|
||||
id: 'alert-rule-add',
|
||||
text: isExisting ? editText : newText,
|
||||
}}
|
||||
>
|
||||
<AlertRuleForm prefill={prefill} isManualRestore={isManualRestore} />
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// The pageNav property makes it difficult to only rely on AlertingPageWrapper
|
||||
// to catch errors.
|
||||
@@ -112,13 +117,16 @@ function useRuleEditorPathParams() {
|
||||
return { identifier, type };
|
||||
}
|
||||
|
||||
function useRuleEditorQueryParams() {
|
||||
const { type } = useParams<RuleEditorPathParams>();
|
||||
|
||||
function useIdentifierFromCopy() {
|
||||
const [searchParams] = useURLSearchParams();
|
||||
const copyFromId = searchParams.get('copyFrom') ?? undefined;
|
||||
const copyFromIdentifier = ruleId.tryParse(copyFromId);
|
||||
const isManualRestore = searchParams.has('isManualRestore');
|
||||
|
||||
return ruleId.tryParse(copyFromId);
|
||||
}
|
||||
|
||||
function useDefaultsFromQuery() {
|
||||
const { type } = useRuleEditorPathParams();
|
||||
const [searchParams] = useURLSearchParams();
|
||||
|
||||
const ruleType = translateRouteParamToRuleType(type);
|
||||
|
||||
@@ -126,5 +134,12 @@ function useRuleEditorQueryParams() {
|
||||
? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType)
|
||||
: undefined;
|
||||
|
||||
return { copyFromIdentifier, queryDefaults, isManualRestore };
|
||||
return queryDefaults;
|
||||
}
|
||||
|
||||
function useManualRestore() {
|
||||
const [searchParams] = useURLSearchParams();
|
||||
const isManualRestore = searchParams.has('isManualRestore');
|
||||
|
||||
return isManualRestore;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('RuleEditor cloud', () => {
|
||||
|
||||
// save and check what was sent to backend
|
||||
const capture = captureRequests();
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
|
||||
@@ -96,27 +96,19 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
//check that folder is in the list
|
||||
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
||||
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
|
||||
expect(screen.getByText('New folder')).toBeInTheDocument();
|
||||
|
||||
//check that slashed folders are not in the list
|
||||
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
||||
expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title));
|
||||
|
||||
//check that slashes warning is only shown once user search slashes
|
||||
//todo: move this test to a unit test in FolderAndGroup unit test
|
||||
// const folderInput = await ui.inputs.folderContainer.find();
|
||||
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
|
||||
// await user.type(within(folderInput).getByRole('combobox'), 'new slashed //');
|
||||
// expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument();
|
||||
// await user.keyboard('{backspace} {backspace}{backspace}');
|
||||
// expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument();
|
||||
|
||||
// add an annotation
|
||||
await user.click(screen.getByText('Add custom annotation'));
|
||||
await user.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom');
|
||||
await user.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value');
|
||||
|
||||
// save and check what was sent to backend
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
});
|
||||
|
||||
it('saves evaluation interval correctly', async () => {
|
||||
@@ -140,7 +132,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
(req) => req.method === 'POST' && req.url.includes('/api/ruler/grafana/api/v1/rules/uuid020c61ef')
|
||||
);
|
||||
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
|
||||
const [request] = await capture;
|
||||
const postBody = await request.json();
|
||||
@@ -158,6 +150,6 @@ describe('Data source managed rules', () => {
|
||||
|
||||
it('should show an error if the data source does not exist', async () => {
|
||||
renderRuleEditor('cri%24grafana-cloudd%24delete me%24delete me 3%24recording_rule_delete_2%24-476183141');
|
||||
expect(await screen.findByText(/unable to find data source/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/not found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
|
||||
import { clickSelectOption, selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||
import { screen } from 'test/test-utils';
|
||||
import { screen, waitFor } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
@@ -73,7 +73,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
await clickSelectOption(groupInput, grafanaRulerGroup.name);
|
||||
await user.type(ui.inputs.annotationValue(1).get(), 'some description');
|
||||
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
|
||||
const requests = await capture;
|
||||
@@ -128,12 +128,12 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
const nameInput = await ui.inputs.name.find();
|
||||
expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title);
|
||||
//check that folder is in the list
|
||||
expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title));
|
||||
await waitFor(() => expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)));
|
||||
expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]);
|
||||
|
||||
expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown
|
||||
|
||||
await user.click(ui.buttons.saveAndExit.get());
|
||||
await user.click(ui.buttons.save.get());
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully');
|
||||
const requests = await capture;
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('RuleEditor recording rules', () => {
|
||||
await userEvent.type(await ui.inputs.expr.find(), 'up == 1');
|
||||
|
||||
// try to save, find out that recording rule name is invalid
|
||||
await userEvent.click(ui.buttons.saveAndExit.get());
|
||||
await userEvent.click(ui.buttons.save.get());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
byText(
|
||||
@@ -95,7 +95,7 @@ describe('RuleEditor recording rules', () => {
|
||||
|
||||
// save and check what was sent to backend
|
||||
const capture = captureRequests();
|
||||
await userEvent.click(ui.buttons.saveAndExit.get());
|
||||
await userEvent.click(ui.buttons.save.get());
|
||||
const requests = await capture;
|
||||
|
||||
const serializedRequests = await serializeRequests(requests);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { generateCopiedName } from '../utils/duplicate';
|
||||
import { getRuleName, rulerRuleType } from '../utils/rules';
|
||||
|
||||
export function changeRuleName(rule: RulerRuleDTO, newName: string) {
|
||||
if (rulerRuleType.grafana.rule(rule)) {
|
||||
rule.grafana_alert.title = newName;
|
||||
}
|
||||
if (rulerRuleType.dataSource.alertingRule(rule)) {
|
||||
rule.alert = newName;
|
||||
}
|
||||
|
||||
if (rulerRuleType.dataSource.recordingRule(rule)) {
|
||||
rule.record = newName;
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneRuleDefinition(rule: RuleWithLocation<RulerRuleDTO>) {
|
||||
const ruleClone = cloneDeep(rule);
|
||||
changeRuleName(
|
||||
ruleClone.rule,
|
||||
generateCopiedName(getRuleName(ruleClone.rule), ruleClone.group.rules.map(getRuleName))
|
||||
);
|
||||
|
||||
if (rulerRuleType.grafana.rule(ruleClone.rule)) {
|
||||
ruleClone.rule.grafana_alert.uid = '';
|
||||
|
||||
// Provisioned alert rules have provisioned alert group which cannot be used in UI
|
||||
if (Boolean(ruleClone.rule.grafana_alert.provenance)) {
|
||||
ruleClone.group = { name: '', rules: ruleClone.group.rules };
|
||||
}
|
||||
}
|
||||
|
||||
return ruleClone;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DataSourceRuleGroupIdentifier,
|
||||
FilterState,
|
||||
RuleIdentifier,
|
||||
RuleWithLocation,
|
||||
RulesSource,
|
||||
SilenceFilterState,
|
||||
} from 'app/types/unified-alerting';
|
||||
@@ -55,6 +56,15 @@ export function createViewLinkV2(
|
||||
return rulesNav.detailsPageLink(ruleSourceName, identifier, returnTo ? { returnTo } : undefined);
|
||||
}
|
||||
|
||||
export function createViewLinkFromRuleWithLocation(ruleWithLocation: RuleWithLocation) {
|
||||
const ruleSourceName = ruleWithLocation.ruleSourceName;
|
||||
const identifier = ruleId.fromRuleWithLocation(ruleWithLocation);
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`);
|
||||
}
|
||||
|
||||
export function createExploreLink(datasource: DataSourceRef, query: string) {
|
||||
const { uid, type } = datasource;
|
||||
|
||||
|
||||
@@ -208,6 +208,10 @@ export function getPendingPeriod(rule: CombinedRule): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getPendingPeriodFromRulerRule(rule: RulerRuleDTO) {
|
||||
return rulerRuleType.any.alertingRule(rule) ? rule.for : undefined;
|
||||
}
|
||||
|
||||
export function getKeepFiringfor(rule: CombinedRule): string | undefined {
|
||||
if (rulerRuleType.any.recordingRule(rule.rulerRule)) {
|
||||
return undefined;
|
||||
|
||||
@@ -408,11 +408,9 @@
|
||||
},
|
||||
"alert-rule-form": {
|
||||
"action-buttons": {
|
||||
"delete": "Delete",
|
||||
"edit-yaml": "Edit YAML",
|
||||
"save-exit": "Save rule and exit"
|
||||
},
|
||||
"title-delete-rule": "Delete rule"
|
||||
"save": "Save"
|
||||
}
|
||||
},
|
||||
"alert-rule-name-and-metric": {
|
||||
"aria-label-name": "name",
|
||||
@@ -895,12 +893,18 @@
|
||||
"text-loading-template": "Loading template...",
|
||||
"title-failed-to-fetch-notification-template": "Failed to fetch notification template"
|
||||
},
|
||||
"editor": {
|
||||
"edit-alert-rule": "Edit alert rule",
|
||||
"edit-recording-rule": "Edit recording rule",
|
||||
"new-alert-rule": "New alert rule",
|
||||
"new-recording-rule": "New recording rule"
|
||||
},
|
||||
"error-modal": {
|
||||
"failed-to-update-your-configuration": "Failed to update your configuration:",
|
||||
"title-something-went-wrong": "Something went wrong"
|
||||
},
|
||||
"evaluation-behavior-summary": {
|
||||
"evaluate": "Every {{every}}",
|
||||
"evaluate": "Every {{interval}}",
|
||||
"label-evaluate": "Evaluate",
|
||||
"label-evaluation-time": "Evaluation time",
|
||||
"label-last-evaluation": "Last evaluation",
|
||||
@@ -929,7 +933,6 @@
|
||||
"existing-rule-editor": {
|
||||
"sorry-permission": "Sorry! You do not have permission to edit this rule.",
|
||||
"sorry-this-rule-does-not-exist": "Sorry! This rule does not exist.",
|
||||
"text-loading-rule": "Loading rule...",
|
||||
"title-cannot-edit-rule": "Cannot edit rule",
|
||||
"title-failed-to-load-rule": "Failed to load rule",
|
||||
"title-rule-not-found": "Rule not found"
|
||||
|
||||
@@ -39,7 +39,7 @@ export const ui = {
|
||||
byTestId(selectors.components.AlertRules.stepAdvancedModeSwitch(stepNo.toString())),
|
||||
},
|
||||
buttons: {
|
||||
saveAndExit: byRole('button', { name: 'Save rule and exit' }),
|
||||
save: byTestId('save-rule'),
|
||||
addAnnotation: byRole('button', { name: /Add info/ }),
|
||||
addLabel: byRole('button', { name: /Add label/ }),
|
||||
preview: byRole('button', { name: /^Preview$/ }),
|
||||
|
||||
Reference in New Issue
Block a user