mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: move alert rule View/Edit/Delete action buttons to collapsed row (#57687)
* Move view edit and delete buttons to RulesTable * Move tests for edit and delete buttons to a new test file for RulesTable * Action buttons: Only show icon for non large screens, and add a Tooltip * Remove buttons moved from the RuleDetailsActionButtons component * Fix horizontal aligment for icons for non large devices
This commit is contained in:
parent
ce38840f29
commit
30cb04f205
@ -6,6 +6,7 @@ import { byTestId } from 'testing-library-selector';
|
||||
|
||||
import { DataSourceApi } from '@grafana/data';
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
import { toggleOption } from 'app/features/variables/pickers/OptionsPicker/reducer';
|
||||
@ -33,8 +34,10 @@ import * as ruleFormUtils from './utils/rule-form';
|
||||
|
||||
jest.mock('./api/prometheus');
|
||||
jest.mock('./api/ruler');
|
||||
jest.mock('../../../core/hooks/useMediaQueryChange');
|
||||
|
||||
jest.spyOn(config, 'getAllDataSources');
|
||||
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
|
||||
|
||||
const dataSources = {
|
||||
prometheus: mockDataSource<PromOptions>({
|
||||
|
@ -8,6 +8,7 @@ import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'
|
||||
|
||||
import { locationService, setDataSourceSrv, logInfo } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto';
|
||||
@ -37,6 +38,8 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
jest.mock('./api/buildInfo');
|
||||
jest.mock('./api/prometheus');
|
||||
jest.mock('./api/ruler');
|
||||
jest.mock('../../../core/hooks/useMediaQueryChange');
|
||||
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
|
||||
jest.mock('app/core/core', () => ({
|
||||
appEvents: {
|
||||
subscribe: () => {
|
||||
|
@ -0,0 +1,218 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
Button,
|
||||
ClipboardButton,
|
||||
ConfirmModal,
|
||||
HorizontalGroup,
|
||||
LinkButton,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
useTheme2,
|
||||
} from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||
import { deleteRuleAction } from '../../state/actions';
|
||||
import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
|
||||
import { createViewLink } from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
|
||||
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
|
||||
|
||||
interface Props {
|
||||
rule: CombinedRule;
|
||||
rulesSource: RulesSource;
|
||||
}
|
||||
function DontShowIfSmallDevice({ children }: { children: JSX.Element | string }) {
|
||||
const theme = useTheme2();
|
||||
const smBreakpoint = theme.breakpoints.values.xxl;
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(matchesWidth(smBreakpoint));
|
||||
const style = useStyles2(getStyles);
|
||||
|
||||
useMediaQueryChange({
|
||||
breakpoint: smBreakpoint,
|
||||
onChange: (e) => {
|
||||
setIsSmallScreen(e.matches);
|
||||
},
|
||||
});
|
||||
|
||||
if (isSmallScreen) {
|
||||
return null;
|
||||
} else {
|
||||
return <div className={style.buttonText}>{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const notifyApp = useAppNotification();
|
||||
const style = useStyles2(getStyles);
|
||||
const { namespace, group, rulerRule } = rule;
|
||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
||||
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const isFederated = isFederatedRuleGroup(group);
|
||||
const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule);
|
||||
const returnTo = location.pathname + location.search;
|
||||
const isViewMode = inViewMode(location.pathname);
|
||||
|
||||
const deleteRule = () => {
|
||||
if (ruleToDelete && ruleToDelete.rulerRule) {
|
||||
const identifier = ruleId.fromRulerRule(
|
||||
getRulesSourceName(ruleToDelete.namespace.rulesSource),
|
||||
ruleToDelete.namespace.name,
|
||||
ruleToDelete.group.name,
|
||||
ruleToDelete.rulerRule
|
||||
);
|
||||
|
||||
dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined }));
|
||||
setRuleToDelete(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const buildShareUrl = () => {
|
||||
if (isCloudRulesSource(rulesSource)) {
|
||||
const { appUrl, appSubUrl } = config;
|
||||
const baseUrl = appSubUrl !== '' ? `${appUrl}${appSubUrl}/` : config.appUrl;
|
||||
const ruleUrl = `${encodeURIComponent(rulesSource.name)}/${encodeURIComponent(rule.name)}`;
|
||||
return `${baseUrl}alerting/${ruleUrl}/find`;
|
||||
}
|
||||
|
||||
return window.location.href.split('?')[0];
|
||||
};
|
||||
|
||||
if (!isViewMode) {
|
||||
buttons.push(
|
||||
<Tooltip placement="top" content={'View'}>
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
key="view"
|
||||
variant="secondary"
|
||||
icon="eye"
|
||||
href={createViewLink(rulesSource, rule, returnTo)}
|
||||
>
|
||||
<DontShowIfSmallDevice>View</DontShowIfSmallDevice>
|
||||
</LinkButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
|
||||
const sourceName = getRulesSourceName(rulesSource);
|
||||
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
|
||||
|
||||
const editURL = urlUtil.renderUrl(
|
||||
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
|
||||
{
|
||||
returnTo,
|
||||
}
|
||||
);
|
||||
|
||||
if (isViewMode) {
|
||||
buttons.push(
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
icon="copy"
|
||||
onClipboardError={(copiedText) => {
|
||||
notifyApp.error('Error while copying URL', copiedText);
|
||||
}}
|
||||
className={style.button}
|
||||
size="sm"
|
||||
getText={buildShareUrl}
|
||||
>
|
||||
Copy link to rule
|
||||
</ClipboardButton>
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<Tooltip placement="top" content={'Edit'}>
|
||||
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
|
||||
<DontShowIfSmallDevice>Edit</DontShowIfSmallDevice>
|
||||
</LinkButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
|
||||
buttons.push(
|
||||
<Tooltip placement="top" content={'Delete'}>
|
||||
<Button
|
||||
className={style.button}
|
||||
size="xs"
|
||||
type="button"
|
||||
key="delete"
|
||||
variant="secondary"
|
||||
icon="trash-alt"
|
||||
onClick={() => setRuleToDelete(rule)}
|
||||
>
|
||||
<DontShowIfSmallDevice>Delete</DontShowIfSmallDevice>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (buttons.length) {
|
||||
return (
|
||||
<>
|
||||
<div className={style.wrapper}>
|
||||
<HorizontalGroup width="auto">
|
||||
{buttons.length ? buttons.map((button, index) => <div key={index}>{button}</div>) : <div />}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{!!ruleToDelete && (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title="Delete rule"
|
||||
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
|
||||
confirmText="Yes, delete"
|
||||
icon="exclamation-triangle"
|
||||
onConfirm={deleteRule}
|
||||
onDismiss={() => setRuleToDelete(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function inViewMode(pathname: string): boolean {
|
||||
return pathname.endsWith('/view');
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
`,
|
||||
button: css`
|
||||
height: 24px;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
svg {
|
||||
margin-right: 0;
|
||||
}
|
||||
`,
|
||||
buttonText: css`
|
||||
margin-left: 8px;
|
||||
`,
|
||||
});
|
@ -9,17 +9,10 @@ import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||
import { mockCombinedRule, mockDataSource, mockPromAlertingRule, mockRulerAlertingRule } from '../../mocks';
|
||||
import { mockCombinedRule } from '../../mocks';
|
||||
|
||||
import { RuleDetails } from './RuleDetails';
|
||||
|
||||
jest.mock('../../hooks/useIsRuleEditable');
|
||||
|
||||
const mocks = {
|
||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||
};
|
||||
|
||||
const ui = {
|
||||
actionButtons: {
|
||||
edit: byRole('link', { name: 'Edit' }),
|
||||
@ -31,51 +24,8 @@ const ui = {
|
||||
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
|
||||
|
||||
describe('RuleDetails RBAC', () => {
|
||||
describe('Grafana rules action buttons', () => {
|
||||
describe('Grafana rules action buttons in details', () => {
|
||||
const grafanaRule = getGrafanaRule({ name: 'Grafana' });
|
||||
it('Should not render Edit button for users without the update permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(grafanaRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render Delete button for users without the delete permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(grafanaRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Edit button for users with the update permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(grafanaRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Delete button for users with the delete permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(grafanaRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render Silence button for users wihout the instance create permission', () => {
|
||||
// Arrange
|
||||
@ -101,53 +51,6 @@ describe('RuleDetails RBAC', () => {
|
||||
expect(ui.actionButtons.silence.query()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud rules action buttons', () => {
|
||||
const cloudRule = getCloudRule({ name: 'Cloud' });
|
||||
it('Should not render Edit button for users without the update permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(cloudRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render Delete button for users without the delete permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(cloudRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Edit button for users with the update permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(cloudRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Delete button for users with the delete permission', () => {
|
||||
// Arrange
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||
|
||||
// Act
|
||||
renderRuleDetails(cloudRule);
|
||||
|
||||
// Assert
|
||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderRuleDetails(rule: CombinedRule) {
|
||||
@ -172,16 +75,3 @@ function getGrafanaRule(override?: Partial<CombinedRule>) {
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
||||
function getCloudRule(override?: Partial<CombinedRule>) {
|
||||
return mockCombinedRule({
|
||||
namespace: {
|
||||
groups: [],
|
||||
name: 'Cortex',
|
||||
rulesSource: mockDataSource(),
|
||||
},
|
||||
promRule: mockPromAlertingRule(),
|
||||
rulerRule: mockRulerAlertingRule(),
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
@ -1,23 +1,17 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { FC, Fragment, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { FC, Fragment } from 'react';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction, useDispatch } from 'app/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
|
||||
import { deleteRuleAction } from '../../state/actions';
|
||||
import { getAlertmanagerByUid } from '../../utils/alertmanager';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
|
||||
import { createExploreLink, createViewLink, makeRuleBasedSilenceLink } from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
|
||||
import { createExploreLink, makeRuleBasedSilenceLink } from '../../utils/misc';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
|
||||
interface Props {
|
||||
@ -26,60 +20,25 @@ interface Props {
|
||||
}
|
||||
|
||||
export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const notifyApp = useAppNotification();
|
||||
const style = useStyles2(getStyles);
|
||||
const { namespace, group, rulerRule } = rule;
|
||||
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
|
||||
const { group } = rule;
|
||||
const alertId = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.id ?? '' : '';
|
||||
const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(alertId);
|
||||
|
||||
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
|
||||
? rulesSource
|
||||
: getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name;
|
||||
const rulesSourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore);
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
const leftButtons: JSX.Element[] = [];
|
||||
const rightButtons: JSX.Element[] = [];
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const isFederated = isFederatedRuleGroup(group);
|
||||
const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule);
|
||||
const returnTo = location.pathname + location.search;
|
||||
const isViewMode = inViewMode(location.pathname);
|
||||
|
||||
const deleteRule = () => {
|
||||
if (ruleToDelete && ruleToDelete.rulerRule) {
|
||||
const identifier = ruleId.fromRulerRule(
|
||||
getRulesSourceName(ruleToDelete.namespace.rulesSource),
|
||||
ruleToDelete.namespace.name,
|
||||
ruleToDelete.group.name,
|
||||
ruleToDelete.rulerRule
|
||||
);
|
||||
|
||||
dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined }));
|
||||
setRuleToDelete(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const buildShareUrl = () => {
|
||||
if (isCloudRulesSource(rulesSource)) {
|
||||
const { appUrl, appSubUrl } = config;
|
||||
const baseUrl = appSubUrl !== '' ? `${appUrl}${appSubUrl}/` : config.appUrl;
|
||||
const ruleUrl = `${encodeURIComponent(rulesSource.name)}/${encodeURIComponent(rule.name)}`;
|
||||
return `${baseUrl}alerting/${ruleUrl}/find`;
|
||||
}
|
||||
|
||||
return window.location.href.split('?')[0];
|
||||
};
|
||||
|
||||
// explore does not support grafana rule queries atm
|
||||
// neither do "federated rules"
|
||||
if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
@ -94,7 +53,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
);
|
||||
}
|
||||
if (rule.annotations[Annotation.runbookURL]) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
@ -111,7 +70,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
if (rule.annotations[Annotation.dashboardUID]) {
|
||||
const dashboardUID = rule.annotations[Annotation.dashboardUID];
|
||||
if (dashboardUID) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
@ -126,7 +85,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
);
|
||||
const panelId = rule.annotations[Annotation.panelID];
|
||||
if (panelId) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
@ -144,7 +103,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
}
|
||||
|
||||
if (alertmanagerSourceName && contextSrv.hasAccess(AccessControlAction.AlertingInstanceCreate, contextSrv.isEditor)) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
@ -159,7 +118,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
}
|
||||
|
||||
if (alertId) {
|
||||
leftButtons.push(
|
||||
buttons.push(
|
||||
<Fragment key="history">
|
||||
<Button className={style.button} size="xs" icon="history" onClick={() => showStateHistoryModal()}>
|
||||
Show state history
|
||||
@ -169,101 +128,17 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isViewMode) {
|
||||
rightButtons.push(
|
||||
<LinkButton
|
||||
className={style.button}
|
||||
size="xs"
|
||||
key="view"
|
||||
variant="secondary"
|
||||
icon="eye"
|
||||
href={createViewLink(rulesSource, rule, returnTo)}
|
||||
>
|
||||
View
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
|
||||
const sourceName = getRulesSourceName(rulesSource);
|
||||
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
|
||||
|
||||
const editURL = urlUtil.renderUrl(
|
||||
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
|
||||
{
|
||||
returnTo,
|
||||
}
|
||||
);
|
||||
|
||||
if (isViewMode) {
|
||||
rightButtons.push(
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
icon="copy"
|
||||
onClipboardError={(copiedText) => {
|
||||
notifyApp.error('Error while copying URL', copiedText);
|
||||
}}
|
||||
className={style.button}
|
||||
size="sm"
|
||||
getText={buildShareUrl}
|
||||
>
|
||||
Copy link to rule
|
||||
</ClipboardButton>
|
||||
);
|
||||
}
|
||||
|
||||
rightButtons.push(
|
||||
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
|
||||
Edit
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
|
||||
rightButtons.push(
|
||||
<Button
|
||||
className={style.button}
|
||||
size="xs"
|
||||
type="button"
|
||||
key="delete"
|
||||
variant="secondary"
|
||||
icon="trash-alt"
|
||||
onClick={() => setRuleToDelete(rule)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (leftButtons.length || rightButtons.length) {
|
||||
if (buttons.length) {
|
||||
return (
|
||||
<>
|
||||
<div className={style.wrapper}>
|
||||
<HorizontalGroup width="auto">{leftButtons.length ? leftButtons : <div />}</HorizontalGroup>
|
||||
<HorizontalGroup width="auto">{rightButtons.length ? rightButtons : <div />}</HorizontalGroup>
|
||||
</div>
|
||||
{!!ruleToDelete && (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title="Delete rule"
|
||||
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
|
||||
confirmText="Yes, delete"
|
||||
icon="exclamation-triangle"
|
||||
onConfirm={deleteRule}
|
||||
onDismiss={() => setRuleToDelete(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<div className={style.wrapper}>
|
||||
<HorizontalGroup width="auto">{buttons.length ? buttons : <div />}</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function inViewMode(pathname: string): boolean {
|
||||
return pathname.endsWith('/view');
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
padding: ${theme.spacing(2)} 0;
|
||||
|
@ -0,0 +1,122 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
|
||||
import { mockCombinedRule, mockDataSource, mockPromAlertingRule, mockRulerAlertingRule } from '../../mocks';
|
||||
|
||||
import { RulesTable } from './RulesTable';
|
||||
|
||||
jest.mock('../../hooks/useIsRuleEditable');
|
||||
|
||||
const mocks = {
|
||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||
};
|
||||
|
||||
const ui = {
|
||||
actionButtons: {
|
||||
edit: byRole('link', { name: 'Edit' }),
|
||||
view: byRole('link', { name: 'View' }),
|
||||
delete: byRole('button', { name: 'Delete' }),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
|
||||
|
||||
function renderRulesTable(rule: CombinedRule) {
|
||||
const store = configureStore();
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<RulesTable rules={[rule]} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getGrafanaRule(override?: Partial<CombinedRule>) {
|
||||
return mockCombinedRule({
|
||||
namespace: {
|
||||
groups: [],
|
||||
name: 'Grafana',
|
||||
rulesSource: 'grafana',
|
||||
},
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
||||
function getCloudRule(override?: Partial<CombinedRule>) {
|
||||
return mockCombinedRule({
|
||||
namespace: {
|
||||
groups: [],
|
||||
name: 'Cortex',
|
||||
rulesSource: mockDataSource(),
|
||||
},
|
||||
promRule: mockPromAlertingRule(),
|
||||
rulerRule: mockRulerAlertingRule(),
|
||||
...override,
|
||||
});
|
||||
}
|
||||
|
||||
describe('RulesTable RBAC', () => {
|
||||
describe('Grafana rules action buttons', () => {
|
||||
const grafanaRule = getGrafanaRule({ name: 'Grafana' });
|
||||
it('Should not render Edit button for users without the update permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
renderRulesTable(grafanaRule);
|
||||
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render Delete button for users without the delete permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
|
||||
renderRulesTable(grafanaRule);
|
||||
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Edit button for users with the update permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||
renderRulesTable(grafanaRule);
|
||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Delete button for users with the delete permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||
renderRulesTable(grafanaRule);
|
||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud rules action buttons', () => {
|
||||
const cloudRule = getCloudRule({ name: 'Cloud' });
|
||||
it('Should not render Edit button for users without the update permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
|
||||
renderRulesTable(cloudRule);
|
||||
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render Delete button for users without the delete permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false });
|
||||
renderRulesTable(cloudRule);
|
||||
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Edit button for users with the update permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
|
||||
renderRulesTable(cloudRule);
|
||||
expect(ui.actionButtons.edit.query()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render Delete button for users with the delete permission', () => {
|
||||
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
|
||||
renderRulesTable(cloudRule);
|
||||
expect(ui.actionButtons.delete.query()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -15,6 +15,7 @@ import { ProvisioningBadge } from '../Provisioning';
|
||||
import { RuleLocation } from '../RuleLocation';
|
||||
import { Tokenize } from '../Tokenize';
|
||||
|
||||
import { RuleActionsButtons } from './RuleActionsButtons';
|
||||
import { RuleConfigStatus } from './RuleConfigStatus';
|
||||
import { RuleDetails } from './RuleDetails';
|
||||
import { RuleHealth } from './RuleHealth';
|
||||
@ -188,6 +189,16 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
||||
size: 5,
|
||||
});
|
||||
}
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: rule }) => {
|
||||
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
|
||||
},
|
||||
size: '290px',
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [hasRuler, rulerRulesLoaded, showSummaryColumn, showGroupColumn]);
|
||||
}, [showSummaryColumn, showGroupColumn, hasRuler, rulerRulesLoaded]);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user