Alerting: Add Modify export feature for Grafana-managed alert rules (#75114)

* Initial POC for modified rule expor

* Add rule and group export options to modified export

* Add feature toggle for modifier export

* Rename GrafanaRuleDesigner to ModifyExportRuleForm to identify it easily as a rule form

* Refactor naming and folder for RuleDesigner => ModifyExport

* Don't render more action drop-down button when no more actions are allowed

* Redirect cancel button to alert list view

* Fix modify export page being reloaded correctly without errors

* Fix test

* Protect modify-export route when toggle-feature is not enabled

* Fix css betterer error

* Address pr review coments

---------

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Sonia Aguilar 2023-09-28 16:07:45 +02:00 committed by GitHub
parent b0b2158dea
commit 169c5262a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 384 additions and 128 deletions

View File

@ -2333,12 +2333,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/alerting/unified/components/rule-editor/AnnotationKeyInput.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -141,6 +141,7 @@ Experimental features might be changed or removed without prior notice.
| `externalCorePlugins` | Allow core plugins to be loaded as external |
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
| `httpSLOLevels` | Adds SLO level to http request metrics |
| `alertingModifiedExport` | Enables using UI for provisioned rules modification and export |
## Development feature toggles

View File

@ -134,4 +134,5 @@ export interface FeatureToggles {
idForwarding?: boolean;
cloudWatchWildCardDimensionValues?: boolean;
externalServiceAccounts?: boolean;
alertingModifiedExport?: boolean;
}

View File

@ -808,5 +808,12 @@ var (
RequiresDevMode: true,
Owner: grafanaAuthnzSquad,
},
{
Name: "alertingModifiedExport",
Description: "Enables using UI for provisioned rules modification and export",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
}
)

View File

@ -115,3 +115,4 @@ httpSLOLevels,experimental,@grafana/hosted-grafana-team,false,false,true,false
idForwarding,experimental,@grafana/grafana-authnz-team,true,false,false,false
cloudWatchWildCardDimensionValues,GA,@grafana/aws-datasources,false,false,false,false
externalServiceAccounts,experimental,@grafana/grafana-authnz-team,true,false,false,false
alertingModifiedExport,experimental,@grafana/alerting-squad,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
115 idForwarding experimental @grafana/grafana-authnz-team true false false false
116 cloudWatchWildCardDimensionValues GA @grafana/aws-datasources false false false false
117 externalServiceAccounts experimental @grafana/grafana-authnz-team true false false false
118 alertingModifiedExport experimental @grafana/alerting-squad false false false false

View File

@ -470,4 +470,8 @@ const (
// FlagExternalServiceAccounts
// Automatic service account and token setup for plugins
FlagExternalServiceAccounts = "externalServiceAccounts"
// FlagAlertingModifiedExport
// Enables using UI for provisioned rules modification and export
FlagAlertingModifiedExport = "alertingModifiedExport"
)

View File

@ -1,5 +1,6 @@
import { uniq } from 'lodash';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { NavLandingPage } from 'app/core/components/NavLandingPage/NavLandingPage';
@ -243,6 +244,19 @@ const unifiedRoutes: RouteDescriptor[] = [
() => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor')
),
},
{
path: '/alerting/:id/modify-export',
pageClass: 'page-alerting',
roles: evaluateAccess([AccessControlAction.AlertingRuleUpdate]),
component: config.featureToggles.alertingModifiedExport
? SafeDynamicImport(
() =>
import(
/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/components/export/GrafanaModifyExport'
)
)
: () => <Redirect to="/alerting/List" />,
},
{
path: '/alerting/:sourceName/:id/view',
pageClass: 'page-alerting',

View File

@ -9,7 +9,7 @@ import { useDispatch } from '../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { fetchEditableRuleAction } from './state/actions';
import { generateCopiedName } from './utils/duplicate';
import { rulerRuleToFormValues } from './utils/rule-form';

View File

@ -6,7 +6,7 @@ import { useDispatch } from 'app/types';
import { RuleIdentifier } from 'app/types/unified-alerting';
import { AlertWarning } from './AlertWarning';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchEditableRuleAction } from './state/actions';

View File

@ -11,7 +11,7 @@ import { AlertWarning } from './AlertWarning';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExistingRuleEditor } from './ExistingRuleEditor';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { AlertRuleForm } from './components/rule-editor/alert-rule-form/AlertRuleForm';
import { useURLSearchParams } from './hooks/useURLSearchParams';
import { fetchRulesSourceBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';

View File

@ -12,6 +12,7 @@ interface GrafanaExportDrawerProps {
children: React.ReactNode;
onClose: () => void;
formatProviders: Array<ExportProvider<ExportFormats>>;
title?: string;
}
export function GrafanaExportDrawer({
@ -20,15 +21,15 @@ export function GrafanaExportDrawer({
children,
onClose,
formatProviders,
title = 'Export',
}: GrafanaExportDrawerProps) {
const grafanaRulesTabs = Object.values(formatProviders).map((provider) => ({
label: provider.name,
value: provider.exportFormat,
}));
return (
<Drawer
title="Export"
title={title}
subtitle="Select the format and download the file or copy the contents to clipboard"
tabs={
<RuleInspectorTabs<ExportFormats> tabs={grafanaRulesTabs} setActiveTab={onTabChange} activeTab={activeTab} />

View File

@ -0,0 +1,112 @@
import { omit } from 'lodash';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { locationService } from '@grafana/runtime';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { GrafanaRouteComponentProps } from '../../../../../core/navigation/types';
import { useDispatch } from '../../../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../../../types/unified-alerting-dto';
import { fetchEditableRuleAction, fetchRulesSourceBuildInfoAction } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form';
import { rulerRuleToFormValues } from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ModifyExportRuleForm } from '../rule-editor/alert-rule-form/ModifyExportRuleForm';
interface GrafanaModifyExportProps extends GrafanaRouteComponentProps<{ id?: string }> {}
// TODO Duplicated in AlertRuleForm
const ignoreHiddenQueries = (ruleDefinition: RuleFormValues): RuleFormValues => {
return {
...ruleDefinition,
queries: ruleDefinition.queries?.map((query) => omit(query, 'model.hide')),
};
};
function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
return ignoreHiddenQueries(rulerRuleToFormValues(rule));
}
export default function GrafanaModifyExport({ match }: GrafanaModifyExportProps) {
const dispatch = useDispatch();
// Get rule source build info
const [ruleIdentifier, setRuleIdentifier] = useState<RuleIdentifier | undefined>(undefined);
useEffect(() => {
const identifier = ruleId.tryParse(match.params.id, true);
setRuleIdentifier(identifier);
}, [match.params.id]);
const { loading: loadingBuildInfo = true } = useAsync(async () => {
if (ruleIdentifier) {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: ruleIdentifier.ruleSourceName }));
}
}, [dispatch, ruleIdentifier]);
// Get rule
const {
loading,
value: alertRule,
error,
} = useAsync(async () => {
if (!ruleIdentifier) {
return;
}
return await dispatch(fetchEditableRuleAction(ruleIdentifier)).unwrap();
}, [ruleIdentifier, loadingBuildInfo]);
if (!ruleIdentifier) {
return <div>Rule not found</div>;
}
if (loading) {
return <LoadingPlaceholder text="Loading the rule" />;
}
if (error) {
return (
<Alert title="Cannot load modify export" severity="error">
{error.message}
</Alert>
);
}
if (!alertRule && !loading && !loadingBuildInfo) {
// alert rule does not exist
return (
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={{ text: 'Modify export' }}>
<Alert
title="Cannot load the rule. The rule does not exist"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
</AlertingPageWrapper>
);
}
if (alertRule && !isGrafanaRulerRule(alertRule.rule)) {
// alert rule exists but is not a grafana-managed rule
return (
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={{ text: 'Modify export' }}>
<Alert
title="This rule is not a Grafana-managed alert rule"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
</AlertingPageWrapper>
);
}
return (
<AlertingPageWrapper isLoading={loading} pageId="alert-list" pageNav={{ text: 'Modify export' }}>
{alertRule && <ModifyExportRuleForm ruleForm={alertRule ? formValuesFromExistingRule(alertRule) : undefined} />}
</AlertingPageWrapper>
);
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Field, Input, Text } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { RuleEditorSection } from './RuleEditorSection';
const recordingRuleNameValidationPattern = {
message:
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
};
export const AlertRuleNameInput = () => {
const {
register,
watch,
formState: { errors },
} = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
return (
<RuleEditorSection
stepNo={1}
title={`Enter ${entityName} name`}
description={
<Text variant="bodySmall" color="secondary">
{/* sigh language rules we should use translations ideally but for now we deal with "a" and "an" */}
Enter {entityName === 'alert rule' ? 'an' : 'a'} {entityName} name to identify your alert.
</Text>
}
>
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input
id="name"
width={35}
{...register('name', {
required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
})}
aria-label="name"
placeholder={`Give your ${entityName} a name`}
/>
</Field>
</RuleEditorSection>
);
};

View File

@ -1,23 +1,13 @@
import { css } from '@emotion/css';
import { omit } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { DeepMap, FieldError, FormProvider, useForm, useFormContext, UseFormWatch } from 'react-hook-form';
import { DeepMap, FieldError, FormProvider, useForm, UseFormWatch } from 'react-hook-form';
import { Link, useParams } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, logInfo } from '@grafana/runtime';
import {
Button,
ConfirmModal,
CustomScrollbar,
Field,
HorizontalGroup,
Input,
Spinner,
Text,
useStyles2,
} from '@grafana/ui';
import { Button, ConfirmModal, CustomScrollbar, HorizontalGroup, Spinner, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/core';
@ -27,73 +17,29 @@ import { useDispatch } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { LogMessages, trackNewAlerRuleFormError } from '../../Analytics';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { deleteRuleAction, saveRuleFormAction } from '../../state/actions';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import { initialAsyncRequestState } from '../../utils/redux';
import { LogMessages, trackNewAlerRuleFormError } from '../../../Analytics';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { deleteRuleAction, saveRuleFormAction } from '../../../state/actions';
import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { initialAsyncRequestState } from '../../../utils/redux';
import {
getDefaultFormValues,
getDefaultQueries,
MINUTE,
normalizeDefaultAnnotations,
rulerRuleToFormValues,
} from '../../utils/rule-form';
import * as ruleId from '../../utils/rule-id';
import { GrafanaRuleExporter } from '../export/GrafanaRuleExporter';
import AnnotationsStep from './AnnotationsStep';
import { CloudEvaluationBehavior } from './CloudEvaluationBehavior';
import { GrafanaEvaluationBehavior } from './GrafanaEvaluationBehavior';
import { NotificationsStep } from './NotificationsStep';
import { RecordingRulesNameSpaceAndGroupStep } from './RecordingRulesNameSpaceAndGroupStep';
import { RuleEditorSection } from './RuleEditorSection';
import { RuleInspector } from './RuleInspector';
import { QueryAndExpressionsStep } from './query-and-alert-condition/QueryAndExpressionsStep';
import { translateRouteParamToRuleType } from './util';
const recordingRuleNameValidationPattern = {
message:
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.',
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
};
const AlertRuleNameInput = () => {
const {
register,
watch,
formState: { errors },
} = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type');
const entityName = ruleFormType === RuleFormType.cloudRecording ? 'recording rule' : 'alert rule';
return (
<RuleEditorSection
stepNo={1}
title={`Enter ${entityName} name`}
description={
<Text variant="bodySmall" color="secondary">
{/* sigh language rules we should use translations ideally but for now we deal with "a" and "an" */}
Enter {entityName === 'alert rule' ? 'an' : 'a'} {entityName} name to identify your alert.
</Text>
}
>
<Field label="Name" error={errors?.name?.message} invalid={!!errors.name?.message}>
<Input
id="name"
width={35}
{...register('name', {
required: { value: true, message: 'Must enter a name' },
pattern: ruleFormType === RuleFormType.cloudRecording ? recordingRuleNameValidationPattern : undefined,
})}
aria-label="name"
placeholder={`Give your ${entityName} a name`}
/>
</Field>
</RuleEditorSection>
);
};
} from '../../../utils/rule-form';
import * as ruleId from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameInput } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
import { CloudEvaluationBehavior } from '../CloudEvaluationBehavior';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
import { NotificationsStep } from '../NotificationsStep';
import { RecordingRulesNameSpaceAndGroupStep } from '../RecordingRulesNameSpaceAndGroupStep';
import { RuleInspector } from '../RuleInspector';
import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep';
import { translateRouteParamToRuleType } from '../util';
type Props = {
existing?: RuleWithLocation;
@ -374,27 +320,24 @@ function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormValues {
function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
return ignoreHiddenQueries(rulerRuleToFormValues(rule));
}
const getStyles = (theme: GrafanaTheme2) => {
return {
buttonSpinner: css`
margin-right: ${theme.spacing(1)};
`,
form: css`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
`,
contentOuter: css`
background: ${theme.colors.background.primary};
overflow: hidden;
flex: 1;
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
`,
};
};
const getStyles = (theme: GrafanaTheme2) => ({
buttonSpinner: css({
marginRight: theme.spacing(1),
}),
form: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
contentOuter: css({
background: theme.colors.background.primary,
overflow: 'hidden',
flex: 1,
}),
flexRow: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
}),
});

View File

@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Stack } from '@grafana/experimental';
import { Button, CustomScrollbar, LinkButton } from '@grafana/ui';
import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate';
import { RuleFormValues } from '../../../types/rule-form';
import { MINUTE } from '../../../utils/rule-form';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
import { allGrafanaExportProviders, ExportFormats } from '../../export/providers';
import { AlertRuleNameInput } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
import { GrafanaEvaluationBehavior } from '../GrafanaEvaluationBehavior';
import { NotificationsStep } from '../NotificationsStep';
import { QueryAndExpressionsStep } from '../query-and-alert-condition/QueryAndExpressionsStep';
interface ModifyExportRuleFormProps {
alertUid?: string;
ruleForm?: RuleFormValues;
}
type ModifyExportMode = 'rule' | 'group';
export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFormProps) {
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
defaultValues: ruleForm,
shouldFocusError: true,
});
const existing = Boolean(ruleForm);
const returnTo = `/alerting/list`;
const [showExporter, setShowExporter] = useState<ModifyExportMode | undefined>(undefined);
const [conditionErrorMsg, setConditionErrorMsg] = useState('');
console.log('conditionErrorMsg', conditionErrorMsg);
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? MINUTE);
const checkAlertCondition = (msg = '') => {
setConditionErrorMsg(msg);
};
const actionButtons = [
<LinkButton href={returnTo} key="cancel" size="sm" variant="secondary">
Cancel
</LinkButton>,
<Button key="export-rule" size="sm" onClick={() => setShowExporter('rule')}>
Export Rule
</Button>,
<Button key="export-group" size="sm" onClick={() => setShowExporter('group')}>
Export Group
</Button>,
];
return (
<>
<FormProvider {...formAPI}>
<AppChromeUpdate actions={actionButtons} />
<form onSubmit={(e) => e.preventDefault()}>
<div>
<CustomScrollbar autoHeightMin="100%" hideHorizontalTrack={true}>
<Stack direction="column" gap={3}>
{/* Step 1 */}
<AlertRuleNameInput />
{/* Step 2 */}
<QueryAndExpressionsStep editingExistingRule={existing} onDataChange={checkAlertCondition} />
{/* Step 3-4-5 */}
<GrafanaEvaluationBehavior
evaluateEvery={evaluateEvery}
setEvaluateEvery={setEvaluateEvery}
existing={Boolean(existing)}
/>
{/* Step 4 & 5 */}
{/* Annotations only for cloud and Grafana */}
<AnnotationsStep />
{/* Notifications step*/}
<NotificationsStep alertUid={alertUid} />
</Stack>
</CustomScrollbar>
</div>
</form>
</FormProvider>
{showExporter && (
<GrafanaRuleDesignExporter exportMode={showExporter} onClose={() => setShowExporter(undefined)} />
)}
</>
);
}
interface GrafanaRuleDesignExporterProps {
onClose: () => void;
exportMode: ModifyExportMode;
}
export const GrafanaRuleDesignExporter = ({ onClose, exportMode }: GrafanaRuleDesignExporterProps) => {
const [activeTab, setActiveTab] = useState<ExportFormats>('yaml');
const title = exportMode === 'rule' ? 'Export Rule' : 'Export Group';
return (
<GrafanaExportDrawer
title={title}
activeTab={activeTab}
onTabChange={setActiveTab}
onClose={onClose}
formatProviders={Object.values(allGrafanaExportProviders)}
>
TODO
</GrafanaExportDrawer>
);
};

View File

@ -6,6 +6,7 @@ import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, locationService } from '@grafana/runtime';
import {
Button,
ClipboardButton,
@ -144,6 +145,20 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
if (isGrafanaRulerRule(rulerRule) && canReadProvisioning) {
moreActions.push(<Menu.Item label="Export" icon="download-alt" onClick={toggleShowExportDrawer} />);
if (config.featureToggles.alertingModifiedExport) {
moreActions.push(
<Menu.Item
label="Modify export"
icon="edit"
onClick={() =>
locationService.push(
`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`
)
}
/>
);
}
}
moreActions.push(
@ -162,20 +177,22 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
{buttons.map((button, index) => (
<React.Fragment key={index}>{button}</React.Fragment>
))}
<Dropdown
overlay={
<Menu>
{moreActions.map((action) => (
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
))}
</Menu>
}
>
<Button variant="secondary" size="sm">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
{moreActions.length > 0 && (
<Dropdown
overlay={
<Menu>
{moreActions.map((action) => (
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
))}
</Menu>
}
>
<Button variant="secondary" size="sm">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
)}
</Stack>
{!!ruleToDelete && (
<ConfirmModal

View File

@ -56,11 +56,9 @@ describe('RulesTable RBAC', () => {
it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
const user = userEvent.setup();
renderRulesTable(grafanaRule);
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
expect(ui.actionButtons.more.query()).not.toBeInTheDocument();
});
it('Should render Edit button for users with the update permission', () => {
@ -91,11 +89,9 @@ describe('RulesTable RBAC', () => {
it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
const user = userEvent.setup();
renderRulesTable(cloudRule);
await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
expect(ui.actionButtons.more.query()).not.toBeInTheDocument();
});
it('Should render Edit button for users with the update permission', () => {