From 620260fabcfb1d39eb86e4089d02fd53709c748e Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 8 May 2025 16:23:45 +0200 Subject: [PATCH] Alerting: Fix alert list V2 preview toggle (#105062) * Use custom storage for previewing alerting features configuration * Fix tests, add localStorage mock * Use store singleton --- .betterer.results | 9 +- .../features/alerting/unified/RuleList.tsx | 6 +- .../rules/Filter/RulesViewModeSelector.tsx | 6 +- .../alerting/unified/featureToggles.test.ts | 123 ++++++++---------- .../alerting/unified/featureToggles.ts | 52 ++------ public/app/features/alerting/unified/mocks.ts | 32 +++++ .../alerting/unified/previewToggles.test.ts | 50 +++++++ .../alerting/unified/previewToggles.ts | 22 ++++ .../rule-list/RuleListPageTitle.test.tsx | 49 +++---- .../unified/rule-list/RuleListPageTitle.tsx | 29 +---- 10 files changed, 203 insertions(+), 175 deletions(-) create mode 100644 public/app/features/alerting/unified/previewToggles.test.ts create mode 100644 public/app/features/alerting/unified/previewToggles.ts diff --git a/.betterer.results b/.betterer.results index 71a3864b644..1e8dfe8bef1 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1078,12 +1078,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/silences/SilencesFilter.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/alerting/unified/featureToggles.ts:5381": [ - [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "0"], - [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "1"], - [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "2"], - [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "3"] - ], "public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -1093,6 +1087,9 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/insights/InsightsMenuButton.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/alerting/unified/mocks.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/alerting/unified/rule-editor/formDefaults.ts:5381": [ [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "0"], [0, 0, 0, "Direct usage of localStorage is not allowed. Use \`Store\` from @grafana/data instead.", "1"], diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index f51f440bd14..c5e8a2dd08e 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -1,13 +1,13 @@ import { Suspense, lazy } from 'react'; -import { config } from '@grafana/runtime'; - +import { shouldUseAlertingListViewV2 } from './featureToggles'; import RuleListV1 from './rule-list/RuleList.v1'; import { withPageErrorBoundary } from './withPageErrorBoundary'; + const RuleListV2 = lazy(() => import('./rule-list/RuleList.v2')); const RuleList = () => { - const newView = config.featureToggles.alertingListViewV2; + const newView = shouldUseAlertingListViewV2(); return {newView ? : }; }; diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx index a0c63afa419..fe8303fdfd1 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx @@ -1,8 +1,8 @@ import { SelectableValue } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { RadioButtonGroup } from '@grafana/ui'; import { trackRulesListViewChange } from '../../../Analytics'; +import { shouldUseAlertingListViewV2 } from '../../../featureToggles'; import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useURLSearchParams } from '../../../hooks/useURLSearchParams'; @@ -66,6 +66,4 @@ function viewParamToLegacyView(viewParam: string | null): LegacySupportedView { return 'grouped'; } -export const RulesViewModeSelector = config.featureToggles.alertingListViewV2 - ? RulesViewModeSelectorV2 - : RulesViewModeSelectorV1; +export const RulesViewModeSelector = shouldUseAlertingListViewV2() ? RulesViewModeSelectorV2 : RulesViewModeSelectorV1; diff --git a/public/app/features/alerting/unified/featureToggles.test.ts b/public/app/features/alerting/unified/featureToggles.test.ts index 0665b102226..7917f449aec 100644 --- a/public/app/features/alerting/unified/featureToggles.test.ts +++ b/public/app/features/alerting/unified/featureToggles.test.ts @@ -1,90 +1,81 @@ -import { setLocalStorageFeatureToggle } from './featureToggles'; +import { config } from '@grafana/runtime'; -const featureTogglesKey = 'grafana.featureToggles'; -const storage = new Map(); +import { shouldUseAlertingListViewV2 } from './featureToggles'; +import { mockLocalStorage } from './mocks'; +import { setPreviewToggle } from './previewToggles'; -const mockLocalStorage = { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => storage.set(key, value), - clear: () => storage.clear(), -}; +const localStorageMock = mockLocalStorage(); Object.defineProperty(window, 'localStorage', { - value: mockLocalStorage, + value: localStorageMock, writable: true, }); -describe('setLocalStorageFeatureToggle', () => { +describe('featureToggles', () => { beforeEach(() => { - storage.clear(); + jest.clearAllMocks(); + localStorageMock.clear(); + config.featureToggles = {}; }); - it('should set feature toggle to true', () => { - setLocalStorageFeatureToggle('alertingListViewV2', true); - expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true'); - }); + describe('shouldUseAlertingListViewV2', () => { + describe('when alertingListViewV2 toggle is disabled', () => { + beforeEach(() => { + config.featureToggles.alertingListViewV2 = false; + }); - it('should set feature toggle to false', () => { - setLocalStorageFeatureToggle('alertingListViewV2', false); - expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=false'); - }); + it('should return false when preview toggle is disabled', () => { + config.featureToggles.alertingListViewV2PreviewToggle = false; - it('should remove feature toggle when set to undefined', () => { - storage.set( - featureTogglesKey, - 'alertingListViewV2=true,alertingPrometheusRulesPrimary=true,alertingCentralAlertHistory=true' - ); + expect(shouldUseAlertingListViewV2()).toBe(false); + }); - setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', undefined); - expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true,alertingCentralAlertHistory=true'); - }); + it('should return true when preview toggle is enabled and preview value is true', () => { + config.featureToggles.alertingListViewV2PreviewToggle = true; + setPreviewToggle('alertingListViewV2', true); - it('should not set undefined when no feature toggles are set', () => { - storage.set(featureTogglesKey, ''); + expect(shouldUseAlertingListViewV2()).toBe(true); + }); - setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', undefined); - expect(storage.get(featureTogglesKey)).toBe(''); - }); + it('should return false when preview toggle is enabled but preview value is false', () => { + config.featureToggles.alertingListViewV2PreviewToggle = true; + setPreviewToggle('alertingListViewV2', false); - it('should update only one feature toggle when multiple feature toggles are set', () => { - storage.set( - featureTogglesKey, - 'alertingListViewV2=true,alertingPrometheusRulesPrimary=true,alertingCentralAlertHistory=true' - ); + expect(shouldUseAlertingListViewV2()).toBe(false); + }); + }); - setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', false); - expect(storage.get(featureTogglesKey)).toBe( - 'alertingListViewV2=true,alertingPrometheusRulesPrimary=false,alertingCentralAlertHistory=true' - ); - }); + describe('when alertingListViewV2 toggle is enabled', () => { + beforeEach(() => { + config.featureToggles.alertingListViewV2 = true; + }); - it('should not rewrite other feature toggles when updating one', () => { - storage.set( - featureTogglesKey, - 'alertingListViewV2=true,alertingPrometheusRulesPrimary=1,alertingCentralAlertHistory=false' - ); + it('should return true when preview toggle is disabled', () => { + config.featureToggles.alertingListViewV2PreviewToggle = false; - setLocalStorageFeatureToggle('alertingListViewV2', false); - expect(storage.get(featureTogglesKey)).toBe( - 'alertingListViewV2=false,alertingPrometheusRulesPrimary=1,alertingCentralAlertHistory=false' - ); - }); + expect(shouldUseAlertingListViewV2()).toBe(true); + }); - it('should add a new toggle when others exist', () => { - storage.set(featureTogglesKey, 'alertingListViewV2=true'); - setLocalStorageFeatureToggle('alertingCentralAlertHistory', true); - expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true,alertingCentralAlertHistory=true'); - }); + it('should return true when preview toggle is disabled even if preview value is true', () => { + config.featureToggles.alertingListViewV2PreviewToggle = false; + setPreviewToggle('alertingListViewV2', true); - it('should remove the only existing toggle', () => { - storage.set(featureTogglesKey, 'alertingListViewV2=true'); - setLocalStorageFeatureToggle('alertingListViewV2', undefined); - expect(storage.get(featureTogglesKey)).toBe(''); - }); + expect(shouldUseAlertingListViewV2()).toBe(true); + }); + + it('should return false when preview toggle is enabled and preview value is false', () => { + config.featureToggles.alertingListViewV2PreviewToggle = true; + setPreviewToggle('alertingListViewV2', false); + + expect(shouldUseAlertingListViewV2()).toBe(false); + }); + + it('should return true when preview toggle is enabled and preview value is true', () => { + config.featureToggles.alertingListViewV2PreviewToggle = true; + setPreviewToggle('alertingListViewV2', true); - it('should not change localStorage when attempting to remove a non-existent toggle', () => { - storage.set(featureTogglesKey, 'alertingListViewV2=true'); - setLocalStorageFeatureToggle('alertingCentralAlertHistory', undefined); - expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true'); + expect(shouldUseAlertingListViewV2()).toBe(true); + }); + }); }); }); diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 1d4ab503836..f40298b36d8 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -1,11 +1,20 @@ -import { FeatureToggles } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { getPreviewToggle } from './previewToggles'; import { isAdmin } from './utils/misc'; export const shouldUsePrometheusRulesPrimary = () => config.featureToggles.alertingPrometheusRulesPrimary ?? false; -export const shouldUseAlertingListViewV2 = () => config.featureToggles.alertingListViewV2 ?? false; +export const shouldUseAlertingListViewV2 = () => { + const previewToggleValue = getPreviewToggle('alertingListViewV2'); + + // If the preview toggle is enabled and has configured value it should take precedence over the feature toggle + if (config.featureToggles.alertingListViewV2PreviewToggle && previewToggleValue !== undefined) { + return previewToggleValue; + } + + return config.featureToggles.alertingListViewV2; +}; export const useGrafanaManagedRecordingRulesSupport = () => config.unifiedAlerting.recordingRulesEnabled && config.featureToggles.grafanaManagedRecordingRules; @@ -15,42 +24,3 @@ export const shouldAllowRecoveringDeletedRules = () => export const shouldAllowPermanentlyDeletingRules = () => (shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; - -export function setLocalStorageFeatureToggle(featureName: keyof FeatureToggles, value: boolean | undefined) { - const featureToggles = localStorage.getItem('grafana.featureToggles') ?? ''; - - const newToggles = updateFeatureToggle(featureToggles, featureName, value); - localStorage.setItem('grafana.featureToggles', newToggles); -} - -function updateFeatureToggle( - featureToggles: string | undefined, - featureName: string, - value: boolean | undefined -): string { - if (!featureToggles) { - if (value !== undefined) { - return `${featureName}=${value}`; - } - return ''; - } - - const parts = featureToggles.split(','); - const featurePrefix = `${featureName}=`; - const featureIndex = parts.findIndex((part) => part.startsWith(featurePrefix)); - - if (featureIndex !== -1) { - if (value === undefined) { - // Remove the feature - parts.splice(featureIndex, 1); - } else { - // Update the feature value - parts[featureIndex] = `${featureName}=${value}`; - } - } else if (value !== undefined) { - // Add new feature - parts.push(`${featureName}=${value}`); - } - - return parts.join(','); -} diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index de3b06d7a08..be3484ba89f 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -814,3 +814,35 @@ export const mockThresholdExpression = (partial: Partial = {}): ...partial, }, }); + +class LocalStorageMock implements Storage { + [key: string]: any; + + getItem(key: string) { + return this[key] ?? null; + } + + setItem(key: string, value: string) { + this[key] = value; + } + + clear() { + Object.keys(this).forEach((key) => delete this[key]); + } + + removeItem(key: string) { + delete this[key]; + } + + key(index: number) { + return Object.keys(this)[index] ?? null; + } + + get length() { + return Object.keys(this).length; + } +} + +export function mockLocalStorage(): Storage { + return new LocalStorageMock(); +} diff --git a/public/app/features/alerting/unified/previewToggles.test.ts b/public/app/features/alerting/unified/previewToggles.test.ts new file mode 100644 index 00000000000..d665566d2c3 --- /dev/null +++ b/public/app/features/alerting/unified/previewToggles.test.ts @@ -0,0 +1,50 @@ +import { mockLocalStorage } from './mocks'; +import { getPreviewToggle, setPreviewToggle } from './previewToggles'; + +const localStorageMock = mockLocalStorage(); +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, +}); + +describe('previewToggles', () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + describe('getPreviewToggle', () => { + it('should return undefined by default for a toggle', () => { + const result = getPreviewToggle('alertingListViewV2'); + expect(result).toBe(undefined); + }); + + it.each([true, false])('should return the stored value for a toggle: %s', (value) => { + // Set the toggle value first + setPreviewToggle('alertingListViewV2', value); + + // Then verify it returns the correct value + const result = getPreviewToggle('alertingListViewV2'); + expect(result).toBe(value); + }); + }); + + describe('setPreviewToggle', () => { + it('should set a toggle value', () => { + setPreviewToggle('alertingListViewV2', true); + + const result = getPreviewToggle('alertingListViewV2'); + expect(result).toBe(true); + }); + + it('should override previous toggle value', () => { + // Set initial value + setPreviewToggle('alertingListViewV2', true); + + // Override with new value + setPreviewToggle('alertingListViewV2', false); + + const result = getPreviewToggle('alertingListViewV2'); + expect(result).toBe(false); + }); + }); +}); diff --git a/public/app/features/alerting/unified/previewToggles.ts b/public/app/features/alerting/unified/previewToggles.ts new file mode 100644 index 00000000000..59f88efe099 --- /dev/null +++ b/public/app/features/alerting/unified/previewToggles.ts @@ -0,0 +1,22 @@ +import { FeatureToggles, store } from '@grafana/data'; + +type AlertingPreviewToggles = Pick; + +const previewToggleStoreKey = 'grafana.alerting.previewToggles'; + +/** + * Get the preview toggle value for the given toggle name. + * @returns The value of the preview toggle or undefined if it is not set. + */ +export function getPreviewToggle(previewToggleName: keyof AlertingPreviewToggles): boolean | undefined { + const previewToggles = store.getObject(previewToggleStoreKey, {}); + + return previewToggles[previewToggleName]; +} + +export function setPreviewToggle(previewToggleName: keyof AlertingPreviewToggles, value: boolean | undefined) { + const previewToggles = store.getObject(previewToggleStoreKey, {}); + + previewToggles[previewToggleName] = value; + store.setObject(previewToggleStoreKey, previewToggles); +} diff --git a/public/app/features/alerting/unified/rule-list/RuleListPageTitle.test.tsx b/public/app/features/alerting/unified/rule-list/RuleListPageTitle.test.tsx index c4fe4479579..7a1618e26b6 100644 --- a/public/app/features/alerting/unified/rule-list/RuleListPageTitle.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleListPageTitle.test.tsx @@ -3,14 +3,12 @@ import { byRole } from 'testing-library-selector'; import { reportInteraction } from '@grafana/runtime'; +import { mockLocalStorage } from '../mocks'; +import { getPreviewToggle, setPreviewToggle } from '../previewToggles'; import { testWithFeatureToggles } from '../test/test-utils'; import { RuleListPageTitle } from './RuleListPageTitle'; -// Constants -const featureTogglesKey = 'grafana.featureToggles'; -const toggleName = 'alertingListViewV2'; - jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), reportInteraction: jest.fn(), @@ -29,32 +27,21 @@ const ui = { disableV2Button: byRole('button', { name: 'Go back to the old look' }), }; +const localStorageMock = mockLocalStorage(); +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, +}); + // Helper function for rendering the component function renderRuleListPageTitle() { - // Mock localStorage - const storage = new Map(); - const mockLocalStorage = { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => storage.set(key, value), - clear: () => storage.clear(), - }; - - Object.defineProperty(window, 'localStorage', { - value: mockLocalStorage, - writable: true, - }); - - const view = render(); - - return { - ...view, - storage, - }; + return render(); } describe('RuleListPageTitle', () => { beforeEach(() => { jest.clearAllMocks(); + localStorageMock.clear(); }); it('should render the title', () => { @@ -79,11 +66,12 @@ describe('RuleListPageTitle', () => { }); it('should enable v2 and reload page when clicked on "Try out the new look!" button', async () => { - const { user, storage } = renderRuleListPageTitle(); + const { user } = renderRuleListPageTitle(); await user.click(ui.enableV2Button.get()); - expect(storage.get(featureTogglesKey)).toBe(`${toggleName}=true`); + const previewToggle = getPreviewToggle('alertingListViewV2'); + expect(previewToggle).toBe(true); expect(mockReload).toHaveBeenCalled(); }); @@ -107,19 +95,18 @@ describe('RuleListPageTitle', () => { }); it('should disable v2 and reload page when clicked on "Go back to the old look" button', async () => { - const { user, storage } = renderRuleListPageTitle(); - storage.set(featureTogglesKey, `${toggleName}=true`); + setPreviewToggle('alertingListViewV2', true); + const { user } = renderRuleListPageTitle(); await user.click(ui.disableV2Button.get()); - // When the toggle is set to undefined, it should be removed from localStorage - expect(storage.get(featureTogglesKey)).toBe(''); + expect(getPreviewToggle('alertingListViewV2')).toBe(false); expect(mockReload).toHaveBeenCalled(); }); it('should report interaction when disabling v2', async () => { - const { user, storage } = renderRuleListPageTitle(); - storage.set(featureTogglesKey, `${toggleName}=true`); + setPreviewToggle('alertingListViewV2', true); + const { user } = renderRuleListPageTitle(); await user.click(ui.disableV2Button.get()); diff --git a/public/app/features/alerting/unified/rule-list/RuleListPageTitle.tsx b/public/app/features/alerting/unified/rule-list/RuleListPageTitle.tsx index fb7c349671c..74ff140af4e 100644 --- a/public/app/features/alerting/unified/rule-list/RuleListPageTitle.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleListPageTitle.tsx @@ -1,22 +1,21 @@ -import { useCallback } from 'react'; - import { config, reportInteraction } from '@grafana/runtime'; import { Button, ButtonProps, Stack } from '@grafana/ui'; import { t } from 'app/core/internationalization'; -import { setLocalStorageFeatureToggle, shouldUseAlertingListViewV2 } from '../featureToggles'; +import { shouldUseAlertingListViewV2 } from '../featureToggles'; +import { setPreviewToggle } from '../previewToggles'; export function RuleListPageTitle({ title }: { title: string }) { const shouldShowV2Toggle = config.featureToggles.alertingListViewV2PreviewToggle ?? false; - const { listViewV2Enabled, enableListViewV2, disableListViewV2 } = useV2AlertListViewToggle(); + const listViewV2Enabled = shouldUseAlertingListViewV2(); const toggleListView = () => { if (listViewV2Enabled) { - disableListViewV2(); + setPreviewToggle('alertingListViewV2', false); reportInteraction('alerting.list_view.v2.disabled'); } else { - enableListViewV2(); + setPreviewToggle('alertingListViewV2', true); reportInteraction('alerting.list_view.v2.enabled'); } window.location.reload(); @@ -49,21 +48,3 @@ export function RuleListPageTitle({ title }: { title: string }) { ); } - -function useV2AlertListViewToggle() { - const listViewV2Enabled = shouldUseAlertingListViewV2(); - - const enableListViewV2 = useCallback(() => { - setLocalStorageFeatureToggle('alertingListViewV2', true); - }, []); - - const disableListViewV2 = useCallback(() => { - setLocalStorageFeatureToggle('alertingListViewV2', undefined); - }, []); - - return { - listViewV2Enabled, - enableListViewV2, - disableListViewV2, - }; -}