[ResponseOps][Rules]Hide rule actions instead of disabling them in the rules list (#216783)

Closes https://github.com/elastic/kibana/issues/210979

## Summary

- This PR updates the rules list and rules details pages to hide actions
for the users with read-only access. Previously, these actions were
disabled, but now they are completely hidden. Specifically:
- on the `Rules List` page, the snooze bell icon and the table row
actions are now hidden for the users with read-only access
- on the `Rule Details` page, the actions button is now hidden
(previously, the users could click on it, but the options were disabled)
    
<img width="1899" alt="Screenshot 2025-04-02 at 14 06 53"
src="https://github.com/user-attachments/assets/712297bf-b807-4ecc-87da-a32cd67d169f"
/>

<img width="1899" alt="Screenshot 2025-04-02 at 14 07 06"
src="https://github.com/user-attachments/assets/a88762fa-feeb-4117-9dc4-31744c752d82"
/>
This commit is contained in:
Georgiana-Andreea Onoleață 2025-04-11 11:52:35 +03:00 committed by GitHub
parent 429a9db67d
commit afc5274fb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 152 additions and 129 deletions

View file

@ -53,7 +53,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -77,7 +76,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -106,7 +104,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -134,7 +131,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -163,7 +159,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -192,7 +187,6 @@ describe('rule_actions_popover', () => {
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={true}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
@ -212,29 +206,4 @@ describe('rule_actions_popover', () => {
expect(screen.queryByText('Run rule')).not.toBeInTheDocument();
});
});
it('disables buttons when the user does not have enough permission', async () => {
const rule = mockRule();
render(
<IntlProvider locale="en">
<RuleActionsPopover
rule={rule}
onDelete={onDeleteMock}
onApiKeyUpdate={onApiKeyUpdateMock}
canSaveRule={false}
onEnableDisable={onEnableDisableMock}
onRunRule={onRunRuleMock}
/>
</IntlProvider>
);
const actionButton = screen.getByTestId('ruleActionsButton');
expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
expect(screen.getByText('Delete rule').closest('button')).toBeDisabled();
expect(screen.getByText('Update API key').closest('button')).toBeDisabled();
expect(screen.getByText('Disable').closest('button')).toBeDisabled();
expect(screen.getByText('Run rule').closest('button')).toBeDisabled();
});
});

View file

@ -13,7 +13,6 @@ import { Rule } from '../../../..';
export interface RuleActionsPopoverProps {
rule: Rule;
canSaveRule: boolean;
onDelete: (ruleId: string) => void;
onApiKeyUpdate: (ruleId: string) => void;
onEnableDisable: (enable: boolean) => void;
@ -22,7 +21,6 @@ export interface RuleActionsPopoverProps {
export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps> = ({
rule,
canSaveRule,
onDelete,
onApiKeyUpdate,
onEnableDisable,
@ -63,7 +61,6 @@ export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps
id: 0,
items: [
{
disabled: !canSaveRule,
'data-test-subj': 'disableButton',
onClick: async () => {
setIsPopoverOpen(false);
@ -80,7 +77,6 @@ export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps
),
},
{
disabled: !canSaveRule,
'data-test-subj': 'updateAPIKeyButton',
onClick: () => {
setIsPopoverOpen(false);
@ -92,7 +88,6 @@ export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps
),
},
{
disabled: !canSaveRule,
'data-test-subj': 'runRuleButton',
onClick: () => {
setIsPopoverOpen(false);
@ -104,7 +99,6 @@ export const RuleActionsPopover: React.FunctionComponent<RuleActionsPopoverProps
),
},
{
disabled: !canSaveRule,
className: 'ruleActionsPopover__deleteButton',
'data-test-subj': 'deleteRuleButton',
onClick: () => {

View file

@ -143,6 +143,32 @@ describe('rule_details', () => {
).toBeFalsy();
});
it('does not render actions button if the user has only read permissions', async () => {
const rule = mockRule();
const mockedRuleType: RuleType = {
id: '.noop',
name: 'No Op',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup,
actionVariables: { context: [], state: [], params: [] },
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
producer: ALERTING_FEATURE_ID,
authorizedConsumers: {
ALERTING_FEATURE_ID: { read: true, all: false },
},
enabledInLicense: true,
category: 'my-category',
isExportable: true,
};
const wrapper = shallowWithIntl(
<RuleDetails rule={rule} ruleType={mockedRuleType} actionTypes={[]} {...mockRuleApis} />
);
expect(wrapper.find('[data-test-subj="ruleActionsButton"]').exists()).toBeFalsy();
});
it('renders the rule error banner with error message, when rule has a license error', () => {
const rule = mockRule({
enabled: true,

View file

@ -418,19 +418,20 @@ export const RuleDetails: React.FunctionComponent<RuleDetailsProps> = ({
</EuiFlexGroup>
}
rightSideItems={[
<RuleActionsPopover
canSaveRule={canSaveRule}
rule={rule}
onDelete={(ruleId) => {
setIsDeleteModalVisibility(true);
setRulesToDelete([ruleId]);
}}
onApiKeyUpdate={(ruleId) => {
setRulesToUpdateAPIKey([ruleId]);
}}
onEnableDisable={onEnableDisable}
onRunRule={onRunRule}
/>,
canSaveRule && (
<RuleActionsPopover
rule={rule}
onDelete={(ruleId) => {
setIsDeleteModalVisibility(true);
setRulesToDelete([ruleId]);
}}
onApiKeyUpdate={(ruleId) => {
setRulesToUpdateAPIKey([ruleId]);
}}
onEnableDisable={onEnableDisable}
onRunRule={onRunRule}
/>
),
editButton,
<EuiButtonEmpty
data-test-subj="refreshRulesButton"

View file

@ -147,7 +147,7 @@ describe('CollapsedItemActions', () => {
jest.useRealTimers();
});
test('renders panel items as disabled', async () => {
test('does not render panel items when rule is not editable', async () => {
const wrapper = mountWithIntl(
<CollapsedItemActions {...getPropsWithRule({ isEditable: false })} />
);
@ -155,9 +155,8 @@ describe('CollapsedItemActions', () => {
await nextTick();
wrapper.update();
});
expect(
wrapper.find('[data-test-subj="selectActionButton"]').first().props().disabled
).toBeTruthy();
expect(wrapper.find('[data-test-subj="selectActionButton"]').exists()).toBeFalsy();
});
test('renders closed popover initially and opens on click with all actions enabled', async () => {
@ -311,21 +310,6 @@ describe('CollapsedItemActions', () => {
expect(wrapper.find(`[data-test-subj="deleteRule"] button`).text()).toEqual('Delete rule');
});
test('renders actions correctly when rule is not editable', async () => {
const wrapper = mountWithIntl(
<CollapsedItemActions {...getPropsWithRule({ isEditable: false })} />
);
wrapper.find('[data-test-subj="selectActionButton"]').first().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(
wrapper.find(`[data-test-subj="selectActionButton"] button`).prop('disabled')
).toBeTruthy();
});
test('renders actions correctly when rule is not enabled due to license', async () => {
const wrapper = mountWithIntl(
<CollapsedItemActions {...getPropsWithRule({ enabledInLicense: false })} />

View file

@ -129,25 +129,6 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
? !ruleTypeRegistry.get(item.ruleTypeId).requiresAppContext
: false;
const button = (
<EuiButtonIcon
disabled={!item.isEditable}
data-test-subj="selectActionButton"
data-testid="selectActionButton"
iconType="boxesHorizontal"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.collapsedItemActons.popoverButtonTitle',
{
defaultMessage: 'Actions for "{name}" column',
values: {
name: item.name,
},
}
)}
/>
);
const isSnoozed = useMemo(() => {
return isRuleSnoozed(item);
}, [item]);
@ -351,28 +332,46 @@ export const CollapsedItemActions: React.FunctionComponent<ComponentOpts> = ({
];
return (
<>
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
ownFocus
panelPaddingSize="none"
data-test-subj="collapsedItemActions"
>
<EuiContextMenu
initialPanelId={0}
panels={panels}
className="actCollapsedItemActions"
data-test-subj="collapsedActionPanel"
data-testid="collapsedActionPanel"
css={collapsedItemActionsCss}
/>
</EuiPopover>
{isUntrackAlertsModalOpen && (
<UntrackAlertsModal onCancel={onDisableModalClose} onConfirm={onDisable} />
)}
</>
item.isEditable && (
<>
<EuiPopover
button={
<EuiButtonIcon
data-test-subj="selectActionButton"
data-testid="selectActionButton"
iconType="boxesHorizontal"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.collapsedItemActons.popoverButtonTitle',
{
defaultMessage: 'Actions for "{name}" column',
values: {
name: item.name,
},
}
)}
/>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
ownFocus
panelPaddingSize="none"
data-test-subj="collapsedItemActions"
>
<EuiContextMenu
initialPanelId={0}
panels={panels}
className="actCollapsedItemActions"
data-test-subj="collapsedActionPanel"
data-testid="collapsedActionPanel"
css={collapsedItemActionsCss}
/>
</EuiPopover>
{isUntrackAlertsModalOpen && (
<UntrackAlertsModal onCancel={onDisableModalClose} onConfirm={onDisable} />
)}
</>
)
);
};

View file

@ -113,6 +113,23 @@ describe('RulesListNotifyBadge', () => {
expect(onRuleChanged).toHaveBeenCalled();
});
it('should not allow the user to snooze the rule unless they have edit permissions', async () => {
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
muteAll: false,
isSnoozedUntil: null,
}}
onRuleChanged={onRuleChanged}
snoozeRule={snoozeRule}
unsnoozeRule={unsnoozeRule}
isRuleEditable={false}
/>
);
expect(screen.queryByTestId('rulesListNotifyBadge')).not.toBeInTheDocument();
});
it('should allow the user to unsnooze rules', async () => {
render(
<RulesListNotifyBadge
@ -181,6 +198,26 @@ describe('RulesListNotifyBadge', () => {
expect(screen.getByTestId('rulesListNotifyBadge-invalidSnooze')).toBeInTheDocument();
});
it('should render the existing snooze schedule as disabled when the user does not have edit permissions', async () => {
render(
<RulesListNotifyBadge
snoozeSettings={{
name: 'rule 1',
muteAll: false,
isSnoozedUntil: moment('1990-02-01').toDate(),
}}
disabled={true}
onRuleChanged={onRuleChanged}
snoozeRule={snoozeRule}
unsnoozeRule={unsnoozeRule}
isRuleEditable={false}
/>
);
expect(screen.getByTestId('rulesListNotifyBadge-snoozed')).toBeInTheDocument();
expect(screen.getByTestId('rulesListNotifyBadge-snoozed')).toBeDisabled();
});
it('should clear an infinitive snooze schedule', async () => {
render(
<RulesListNotifyBadge

View file

@ -73,11 +73,13 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
unsnoozeRule,
showOnHover = false,
showTooltipInline = false,
isRuleEditable = true,
}) => {
const [requestInFlight, setRequestInFlightLoading] = useState(false);
const isLoading = loading || requestInFlight;
const isDisabled = Boolean(disabled) || !snoozeSettings;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => {
setIsPopoverOpen((prev) => {
const newState = !prev;
@ -303,6 +305,9 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
}, [isLoading, isDisabled, snoozeButtonAriaLabel, togglePopover]);
const button = useMemo(() => {
if (!isScheduled && !isSnoozed && !isSnoozedIndefinitely && !isRuleEditable) {
return null;
}
if (!isSnoozeValid) {
return (
<InvalidSnoozeButton
@ -337,14 +342,17 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
scheduledSnoozeButton,
indefiniteSnoozeButton,
snoozedButton,
isRuleEditable,
]);
const buttonWithToolTip = useMemo(() => {
if (!isSnoozeValid) {
return (
<EuiToolTip title={INVALID_SNOOZE_TOOLTIP_TITLE} content={INVALID_SNOOZE_TOOLTIP_CONTENT}>
{button}
</EuiToolTip>
button && (
<EuiToolTip title={INVALID_SNOOZE_TOOLTIP_TITLE} content={INVALID_SNOOZE_TOOLTIP_CONTENT}>
{button}
</EuiToolTip>
)
);
}
@ -356,21 +364,23 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
: snoozeTimeLeft;
return (
<EuiToolTip
title={
tooltipContent
? i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.timeRemaining',
{
defaultMessage: 'Time remaining',
}
)
: undefined
}
content={tooltipContent}
>
{button}
</EuiToolTip>
button && (
<EuiToolTip
title={
tooltipContent
? i18n.translate(
'xpack.triggersActionsUI.sections.rulesList.rulesListNotifyBadge.timeRemaining',
{
defaultMessage: 'Time remaining',
}
)
: undefined
}
content={tooltipContent}
>
{button}
</EuiToolTip>
)
);
}, [isSnoozeValid, disabled, isPopoverOpen, showTooltipInline, snoozeTimeLeft, button]);
@ -392,7 +402,7 @@ export const RulesListNotifyBadge: React.FunctionComponent<RulesListNotifyBadgeP
[closePopover, snoozeRule, onRuleChanged, toasts]
);
const popover = (
const popover = buttonWithToolTip && (
<EuiPopover
data-test-subj="rulesListNotifyBadge"
isOpen={isPopoverOpen && !isDisabled}

View file

@ -26,6 +26,7 @@ export interface RulesListNotifyBadgeProps {
unsnoozeRule: (scheduleIds?: string[]) => Promise<void>;
showTooltipInline?: boolean;
showOnHover?: boolean;
isRuleEditable?: boolean;
}
export type RulesListNotifyBadgePropsWithApi = Pick<

View file

@ -539,6 +539,7 @@ export const RulesListTable = (props: RulesListTableProps) => {
if (!rule.enabled) {
return null;
}
return (
<RulesListNotifyBadge
showOnHover
@ -550,6 +551,7 @@ export const RulesListTable = (props: RulesListTableProps) => {
await onSnoozeRule(rule, snoozeSchedule);
}}
unsnoozeRule={async (scheduleIds) => await onUnsnoozeRule(rule, scheduleIds)}
isRuleEditable={rule.isEditable}
/>
);
},