Alerting: Add ruleUid prop and improve test coverage (#111118)

* add isruleEditable check in per rule ui and details page

* add tests

* update translations

* revert logic for not editable rules => we will allow adding/deleting/updating enrichments per this rule

* update translations

* update test

* pr feedback

* lint
This commit is contained in:
Sonia Aguilar 2025-09-19 08:30:56 +02:00 committed by GitHub
parent 0e7a5ffc86
commit 96f70df167
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 201 additions and 15 deletions

View File

@ -0,0 +1,97 @@
import { ComponentType } from 'react';
import { render, screen } from 'test/test-utils';
import {
EnrichmentDrawerExtension,
EnrichmentDrawerExtensionProps,
addEnrichmentDrawerExtension,
} from './EnrichmentDrawerExtension';
// Mock component for testing
const MockEnrichmentDrawer: ComponentType<EnrichmentDrawerExtensionProps> = ({ ruleUid, onClose }) => (
<div data-testid="enrichment-drawer">
<div data-testid="rule-uid">{ruleUid}</div>
<button data-testid="close-button" onClick={onClose}>
Close
</button>
</div>
);
describe('EnrichmentDrawerExtension', () => {
const mockOnClose = jest.fn();
const defaultProps = {
ruleUid: 'test-rule-uid',
onClose: mockOnClose,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render nothing when no extension is registered', () => {
render(<EnrichmentDrawerExtension {...defaultProps} />);
expect(screen.queryByTestId('enrichment-drawer')).not.toBeInTheDocument();
});
it('should render registered extension with correct props', () => {
addEnrichmentDrawerExtension(MockEnrichmentDrawer);
render(<EnrichmentDrawerExtension {...defaultProps} />);
expect(screen.getByTestId('enrichment-drawer')).toBeInTheDocument();
expect(screen.getByTestId('rule-uid')).toHaveTextContent('test-rule-uid');
});
it('should call onClose when close button is clicked', () => {
addEnrichmentDrawerExtension(MockEnrichmentDrawer);
render(<EnrichmentDrawerExtension {...defaultProps} />);
const closeButton = screen.getByTestId('close-button');
closeButton.click();
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should handle different rule UIDs', () => {
addEnrichmentDrawerExtension(MockEnrichmentDrawer);
const differentRuleUid = 'different-rule-uid';
render(<EnrichmentDrawerExtension {...defaultProps} ruleUid={differentRuleUid} />);
expect(screen.getByTestId('rule-uid')).toHaveTextContent(differentRuleUid);
});
it('should re-render when ruleUid prop changes', () => {
addEnrichmentDrawerExtension(MockEnrichmentDrawer);
const { rerender } = render(<EnrichmentDrawerExtension {...defaultProps} ruleUid="rule-1" />);
expect(screen.getByTestId('rule-uid')).toHaveTextContent('rule-1');
rerender(<EnrichmentDrawerExtension {...defaultProps} ruleUid="rule-2" />);
expect(screen.getByTestId('rule-uid')).toHaveTextContent('rule-2');
});
it('should handle error boundary when extension throws', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const FailingComponent: ComponentType<EnrichmentDrawerExtensionProps> = () => {
throw new Error('Test error');
};
addEnrichmentDrawerExtension(FailingComponent);
// Should not throw, error boundary should catch it
expect(() => {
render(<EnrichmentDrawerExtension {...defaultProps} />);
}).not.toThrow();
// Error boundary should render fallback UI
expect(screen.getByText(/Enrichment Drawer Extension failed to load/i)).toBeInTheDocument();
consoleSpy.mockRestore();
});
});

View File

@ -15,9 +15,7 @@ import {
AlertRuleAction,
skipToken,
useGrafanaPromRuleAbilities,
useGrafanaPromRuleAbility,
useRulerRuleAbilities,
useRulerRuleAbility,
} from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
@ -89,16 +87,6 @@ const AlertRuleMenu = ({
AlertRuleAction.ModifyExport,
]);
const [editRuleSupported, editRuleAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Update);
// If the consumer of this component comes from the alert list view, we need to use promRule to check abilities and permissions,
// as we have removed all requests to the ruler API in the list view.
const [grafanaEditRuleSupported, grafanaEditRuleAllowed] = useGrafanaPromRuleAbility(
prometheusRuleType.grafana.rule(promRule) ? promRule : skipToken,
AlertRuleAction.Update
);
const canEditRule = (editRuleSupported && editRuleAllowed) || (grafanaEditRuleSupported && grafanaEditRuleAllowed);
const [pauseSupported, pauseAllowed] = rulerPauseAbility;
const [grafanaPauseSupported, grafanaPauseAllowed] = grafanaPauseAbility;
const canPause = (pauseSupported && pauseAllowed) || (grafanaPauseSupported && grafanaPauseAllowed);
@ -148,7 +136,6 @@ const AlertRuleMenu = ({
// todo: make this new menu item for enrichments an extension of the alertrulemenu items. For first iteration, we'll keep it here.
const canManageEnrichments =
canEditRule &&
ruleUid &&
handleManageEnrichments &&
config.featureToggles.alertingEnrichmentPerRule &&

View File

@ -30,6 +30,7 @@ import { stringifyIdentifier } from '../../utils/rule-id';
import { AlertRuleProvider } from './RuleContext';
import RuleViewer, { ActiveTab } from './RuleViewer';
import { addRulePageEnrichmentSection } from './tabs/extensions/RuleViewerExtension';
// metadata and interactive elements
const ELEMENTS = {
@ -411,6 +412,79 @@ describe('RuleViewer', () => {
expect(ELEMENTS.details.pendingPeriod.get()).toHaveTextContent(/15m/i);
});
});
describe('Enrichment tab', () => {
const mockRule = getGrafanaRule(
{
name: 'Test alert',
uid: 'test-rule-uid',
annotations: {
[Annotation.summary]: 'This is the summary for the rule',
},
labels: {
team: 'operations',
severity: 'low',
},
group: {
name: 'my-group',
interval: '15m',
rules: [],
totals: { alerting: 1 },
},
},
{ uid: grafanaRulerRule.grafana_alert.uid }
);
const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule);
beforeEach(() => {
grantPermissionsHelper([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstanceCreate,
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingInstancesExternalWrite,
]);
});
it('should pass correct props to enrichment section extension for editable rule', async () => {
const mockEnrichmentExtension = jest.fn(() => <div data-testid="enrichment-section">Enrichment Section</div>);
addRulePageEnrichmentSection(mockEnrichmentExtension);
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Enrichment);
expect(mockEnrichmentExtension).toHaveBeenCalledWith(
expect.objectContaining({
ruleUid: 'test-rule-uid',
}),
expect.any(Object)
);
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
});
it('should pass correct props to enrichment section extension for read-only rule', async () => {
grantPermissionsHelper([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstancesExternalRead,
]);
const mockEnrichmentExtension = jest.fn(() => <div data-testid="enrichment-section">Enrichment Section</div>);
addRulePageEnrichmentSection(mockEnrichmentExtension);
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Enrichment);
expect(mockEnrichmentExtension).toHaveBeenCalledWith(
expect.objectContaining({
ruleUid: 'test-rule-uid',
}),
expect.any(Object)
);
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
});
});
});
const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier, tab: ActiveTab = ActiveTab.Query) => {

View File

@ -174,7 +174,7 @@ const RuleViewer = () => {
{activeTab === ActiveTab.VersionHistory && rulerRuleType.grafana.rule(rule.rulerRule) && (
<AlertVersionHistory rule={rule.rulerRule} />
)}
{activeTab === ActiveTab.Enrichment && <RulePageEnrichmentSectionExtension />}
{activeTab === ActiveTab.Enrichment && rule.uid && <RulePageEnrichmentSectionExtension ruleUid={rule.uid} />}
</TabContent>
</Stack>
{duplicateRuleIdentifier && (

View File

@ -5,7 +5,9 @@ import { withErrorBoundary } from '@grafana/ui';
import { logError } from '../../../../Analytics';
export interface RuleViewerExtensionProps {}
export interface RuleViewerExtensionProps {
ruleUid: string;
}
let InternalRulePageEnrichmentSection: ComponentType<RuleViewerExtensionProps> | null = null;

View File

@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it } from '@jest/globals';
import { addRulePageEnrichmentSection } from '../../components/rule-viewer/tabs/extensions/RuleViewerExtension';
import { __clearRuleViewTabsForTests, addEnrichmentSection, getRuleViewExtensionTabs } from './extensions';
describe('rule-view-page navigation', () => {
@ -28,4 +30,28 @@ describe('rule-view-page navigation', () => {
expect(enrichment).toBeTruthy();
expect(enrichment!.active).toBe(true);
});
describe('enrichment section registration', () => {
it('should register enrichment section with correct prop interface', () => {
const mockEnrichmentSection = jest.fn(() => null);
// This should not throw an error
expect(() => {
addRulePageEnrichmentSection(mockEnrichmentSection);
}).not.toThrow();
});
it('should handle enrichment section with required props', () => {
const mockEnrichmentSection = jest.fn((props: { ruleUid: string }) => {
expect(props).toHaveProperty('ruleUid');
expect(typeof props.ruleUid).toBe('string');
return null;
});
addRulePageEnrichmentSection(mockEnrichmentSection);
// The registration should succeed
expect(mockEnrichmentSection).toBeDefined();
});
});
});