Alerting: Add a button to try out the new list page (#103855)

* Add user-facing feature toggle functionality for the new alerting list view

- Implemented `useFeatureToggle` hook to manage feature toggles using local storage.
- Added unit tests for `useFeatureToggle` to verify behavior for various toggle states.
- Updated `RuleList` components to utilize the new feature toggle for alerting list view.
- Introduced `RuleListPageTitle` component to handle toggling between list views with a badge indicator.

* Add tests

* Fix imports and remove unused code

* Add a new feature flag for list v2 preview button

* Hide v2 preview button behind the new feature flag

* Update list v2 feature toggle stage

* Alerting: List view feature toggle button PR review (#104161)

* Add test for undefined feature toggles case

* Tweak tests to use test utils and user

* Add i18n for toggle button and tweak props spreading

* Update translations

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
pull/104268/head
Konrad Lalik 3 months ago committed by GitHub
parent 17e4a3b386
commit 512df0091a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      apps/alerting/notifications/pkg/apis/alerting_manifest.go
  2. 2
      apps/alerting/notifications/pkg/apis/receiver/v0alpha1/receiver_schema_gen.go
  3. 2
      apps/alerting/notifications/pkg/apis/templategroup/v0alpha1/templategroup_schema_gen.go
  4. 2
      apps/alerting/notifications/pkg/apis/timeinterval/v0alpha1/timeinterval_schema_gen.go
  5. 4
      packages/grafana-data/src/types/featureToggles.gen.ts
  6. 9
      pkg/services/featuremgmt/registry.go
  7. 3
      pkg/services/featuremgmt/toggles_gen.csv
  8. 4
      pkg/services/featuremgmt/toggles_gen.go
  9. 25
      pkg/services/featuremgmt/toggles_gen.json
  10. 2
      pkg/util/xorm/dialect_spanner.go
  11. 2
      pkg/util/xorm/dialect_sqlite3.go
  12. 2
      pkg/util/xorm/engine.go
  13. 2
      pkg/util/xorm/engine_cond.go
  14. 2
      pkg/util/xorm/session_exist.go
  15. 2
      pkg/util/xorm/session_find.go
  16. 2
      pkg/util/xorm/session_insert.go
  17. 2
      pkg/util/xorm/session_query.go
  18. 2
      pkg/util/xorm/session_raw.go
  19. 2
      pkg/util/xorm/session_update.go
  20. 2
      pkg/util/xorm/statement.go
  21. 2
      pkg/util/xorm/statement_args.go
  22. 90
      public/app/features/alerting/unified/featureToggles.test.ts
  23. 40
      public/app/features/alerting/unified/featureToggles.ts
  24. 3
      public/app/features/alerting/unified/rule-list/RuleList.v1.tsx
  25. 8
      public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
  26. 129
      public/app/features/alerting/unified/rule-list/RuleListPageTitle.test.tsx
  27. 69
      public/app/features/alerting/unified/rule-list/RuleListPageTitle.tsx
  28. 4
      public/locales/en-US/grafana.json

@ -11,8 +11,6 @@ import (
"github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/app"
) )
var ()
var appManifestData = app.ManifestData{ var appManifestData = app.ManifestData{
AppName: "alerting", AppName: "alerting",
Group: "notifications.alerting.grafana.app", Group: "notifications.alerting.grafana.app",

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites // schema is unexported to prevent accidental overwrites
var ( var (
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"), schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &Receiver{}, &ReceiverList{}, resource.WithKind("Receiver"),
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title", FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) { FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Receiver) cast, ok := o.(*Receiver)

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites // schema is unexported to prevent accidental overwrites
var ( var (
schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"), schemaTemplateGroup = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TemplateGroup{}, &TemplateGroupList{}, resource.WithKind("TemplateGroup"),
resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ resource.WithPlural("templategroups"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title", FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) { FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TemplateGroup) cast, ok := o.(*TemplateGroup)

@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites // schema is unexported to prevent accidental overwrites
var ( var (
schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"), schemaTimeInterval = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", &TimeInterval{}, &TimeIntervalList{}, resource.WithKind("TimeInterval"),
resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ resource.WithPlural("timeintervals"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.name", FieldSelector: "spec.name",
FieldValueFunc: func(o resource.Object) (string, error) { FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*TimeInterval) cast, ok := o.(*TimeInterval)

@ -1026,4 +1026,8 @@ export interface FeatureToggles {
* Enables auto-updating of users installed plugins * Enables auto-updating of users installed plugins
*/ */
pluginsAutoUpdate?: boolean; pluginsAutoUpdate?: boolean;
/**
* Enables the alerting list view v2 preview toggle
*/
alertingListViewV2PreviewToggle?: boolean;
} }

@ -1026,7 +1026,7 @@ var (
{ {
Name: "alertingListViewV2", Name: "alertingListViewV2",
Description: "Enables the new alert list view design", Description: "Enables the new alert list view design",
Stage: FeatureStageExperimental, Stage: FeatureStagePrivatePreview,
Owner: grafanaAlertingSquad, Owner: grafanaAlertingSquad,
FrontendOnly: true, FrontendOnly: true,
}, },
@ -1767,6 +1767,13 @@ var (
FrontendOnly: false, FrontendOnly: false,
Owner: grafanaPluginsPlatformSquad, Owner: grafanaPluginsPlatformSquad,
}, },
{
Name: "alertingListViewV2PreviewToggle",
Description: "Enables the alerting list view v2 preview toggle",
FrontendOnly: true,
Stage: FeatureStagePrivatePreview,
Owner: grafanaAlertingSquad,
},
} }
) )

@ -132,7 +132,7 @@ grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,fa
queryLibrary,experimental,@grafana/grafana-frontend-platform,false,false,false queryLibrary,experimental,@grafana/grafana-frontend-platform,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
newDashboardSharingComponent,GA,@grafana/sharing-squad,false,false,true newDashboardSharingComponent,GA,@grafana/sharing-squad,false,false,true
alertingListViewV2,experimental,@grafana/alerting-squad,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true
@ -231,3 +231,4 @@ unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true
logsPanelControls,preview,@grafana/observability-logs,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true
metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true
pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
132 queryLibrary experimental @grafana/grafana-frontend-platform false false false
133 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
134 newDashboardSharingComponent GA @grafana/sharing-squad false false true
135 alertingListViewV2 experimental privatePreview @grafana/alerting-squad false false true
136 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
137 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
138 alertingCentralAlertHistory experimental @grafana/alerting-squad false false true
231 logsPanelControls preview @grafana/observability-logs false false true
232 metricsFromProfiles experimental @grafana/observability-traces-and-profiling false false true
233 pluginsAutoUpdate experimental @grafana/plugins-platform-backend false false false
234 alertingListViewV2PreviewToggle privatePreview @grafana/alerting-squad false false true

@ -934,4 +934,8 @@ const (
// FlagPluginsAutoUpdate // FlagPluginsAutoUpdate
// Enables auto-updating of users installed plugins // Enables auto-updating of users installed plugins
FlagPluginsAutoUpdate = "pluginsAutoUpdate" FlagPluginsAutoUpdate = "pluginsAutoUpdate"
// FlagAlertingListViewV2PreviewToggle
// Enables the alerting list view v2 preview toggle
FlagAlertingListViewV2PreviewToggle = "alertingListViewV2PreviewToggle"
) )

@ -224,12 +224,31 @@
{ {
"metadata": { "metadata": {
"name": "alertingListViewV2", "name": "alertingListViewV2",
"resourceVersion": "1743693517832", "resourceVersion": "1744700823766",
"creationTimestamp": "2024-05-24T14:40:49Z" "creationTimestamp": "2024-05-24T14:40:49Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-04-15 07:07:03.766981 +0000 UTC"
}
}, },
"spec": { "spec": {
"description": "Enables the new alert list view design", "description": "Enables the new alert list view design",
"stage": "experimental", "stage": "privatePreview",
"codeowner": "@grafana/alerting-squad",
"frontend": true
}
},
{
"metadata": {
"name": "alertingListViewV2PreviewToggle",
"resourceVersion": "1744700823766",
"creationTimestamp": "2025-04-14T13:28:02Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-04-15 07:07:03.766981 +0000 UTC"
}
},
"spec": {
"description": "Enables the alerting list view v2 preview toggle",
"stage": "privatePreview",
"codeowner": "@grafana/alerting-squad", "codeowner": "@grafana/alerting-squad",
"frontend": true "frontend": true
} }

@ -11,8 +11,8 @@ import (
spannerclient "cloud.google.com/go/spanner" spannerclient "cloud.google.com/go/spanner"
_ "github.com/googleapis/go-sql-spanner" _ "github.com/googleapis/go-sql-spanner"
spannerdriver "github.com/googleapis/go-sql-spanner" spannerdriver "github.com/googleapis/go-sql-spanner"
"google.golang.org/grpc/codes"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"google.golang.org/grpc/codes"
) )
func init() { func init() {

@ -11,8 +11,8 @@ import (
"regexp" "regexp"
"strings" "strings"
sqlite "github.com/mattn/go-sqlite3"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
sqlite "github.com/mattn/go-sqlite3"
) )
var ( var (

@ -15,8 +15,8 @@ import (
"sync" "sync"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
// Engine is the major struct of xorm, it means a database manager. // Engine is the major struct of xorm, it means a database manager.

@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
func (engine *Engine) buildConds(table *core.Table, bean any, func (engine *Engine) buildConds(table *core.Table, bean any,

@ -9,8 +9,8 @@ import (
"fmt" "fmt"
"reflect" "reflect"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
// Exist returns true if the record exist otherwise return false // Exist returns true if the record exist otherwise return false

@ -9,8 +9,8 @@ import (
"reflect" "reflect"
"strings" "strings"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
const ( const (

@ -12,8 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
// ErrNoElementsOnSlice represents an error there is no element when insert // ErrNoElementsOnSlice represents an error there is no element when insert

@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
func (session *Session) genQuerySQL(sqlOrArgs ...interface{}) (string, []interface{}, error) { func (session *Session) genQuerySQL(sqlOrArgs ...interface{}) (string, []interface{}, error) {

@ -9,8 +9,8 @@ import (
"reflect" "reflect"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
func (session *Session) queryPreprocess(sqlStr *string, paramStr ...any) { func (session *Session) queryPreprocess(sqlStr *string, paramStr ...any) {

@ -10,8 +10,8 @@ import (
"reflect" "reflect"
"strings" "strings"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
// Update records, bean's non-empty fields are updated contents, // Update records, bean's non-empty fields are updated contents,

@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
// Statement save all the sql info for executing SQL // Statement save all the sql info for executing SQL

@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"xorm.io/builder"
"github.com/grafana/grafana/pkg/util/xorm/core" "github.com/grafana/grafana/pkg/util/xorm/core"
"xorm.io/builder"
) )
func quoteNeeded(a any) bool { func quoteNeeded(a any) bool {

@ -0,0 +1,90 @@
import { setLocalStorageFeatureToggle } from './featureToggles';
const featureTogglesKey = 'grafana.featureToggles';
const storage = new Map<string, string>();
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,
});
describe('setLocalStorageFeatureToggle', () => {
beforeEach(() => {
storage.clear();
});
it('should set feature toggle to true', () => {
setLocalStorageFeatureToggle('alertingListViewV2', true);
expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true');
});
it('should set feature toggle to false', () => {
setLocalStorageFeatureToggle('alertingListViewV2', false);
expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=false');
});
it('should remove feature toggle when set to undefined', () => {
storage.set(
featureTogglesKey,
'alertingListViewV2=true,alertingPrometheusRulesPrimary=true,alertingCentralAlertHistory=true'
);
setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', undefined);
expect(storage.get(featureTogglesKey)).toBe('alertingListViewV2=true,alertingCentralAlertHistory=true');
});
it('should not set undefined when no feature toggles are set', () => {
storage.set(featureTogglesKey, '');
setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', undefined);
expect(storage.get(featureTogglesKey)).toBe('');
});
it('should update only one feature toggle when multiple feature toggles are set', () => {
storage.set(
featureTogglesKey,
'alertingListViewV2=true,alertingPrometheusRulesPrimary=true,alertingCentralAlertHistory=true'
);
setLocalStorageFeatureToggle('alertingPrometheusRulesPrimary', false);
expect(storage.get(featureTogglesKey)).toBe(
'alertingListViewV2=true,alertingPrometheusRulesPrimary=false,alertingCentralAlertHistory=true'
);
});
it('should not rewrite other feature toggles when updating one', () => {
storage.set(
featureTogglesKey,
'alertingListViewV2=true,alertingPrometheusRulesPrimary=1,alertingCentralAlertHistory=false'
);
setLocalStorageFeatureToggle('alertingListViewV2', false);
expect(storage.get(featureTogglesKey)).toBe(
'alertingListViewV2=false,alertingPrometheusRulesPrimary=1,alertingCentralAlertHistory=false'
);
});
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 remove the only existing toggle', () => {
storage.set(featureTogglesKey, 'alertingListViewV2=true');
setLocalStorageFeatureToggle('alertingListViewV2', undefined);
expect(storage.get(featureTogglesKey)).toBe('');
});
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');
});
});

@ -1,3 +1,4 @@
import { FeatureToggles } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { isAdmin } from './utils/misc'; import { isAdmin } from './utils/misc';
@ -14,3 +15,42 @@ export const shouldAllowRecoveringDeletedRules = () =>
export const shouldAllowPermanentlyDeletingRules = () => export const shouldAllowPermanentlyDeletingRules = () =>
(shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false; (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(',');
}

@ -29,6 +29,8 @@ import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
import { createRelativeUrl } from '../utils/url'; import { createRelativeUrl } from '../utils/url';
import { RuleListPageTitle } from './RuleListPageTitle';
const VIEWS = { const VIEWS = {
groups: RuleListGroupView, groups: RuleListGroupView,
state: RuleListStateView, state: RuleListStateView,
@ -123,6 +125,7 @@ const RuleListV1 = () => {
<AlertingPageWrapper <AlertingPageWrapper
navId="alert-list" navId="alert-list"
isLoading={false} isLoading={false}
renderTitle={(title) => <RuleListPageTitle title={title} />}
actions={ actions={
hasAlertRulesCreated && ( hasAlertRulesCreated && (
<Stack gap={1}> <Stack gap={1}>

@ -12,6 +12,7 @@ import { useURLSearchParams } from '../hooks/useURLSearchParams';
import { FilterView } from './FilterView'; import { FilterView } from './FilterView';
import { GroupedView } from './GroupedView'; import { GroupedView } from './GroupedView';
import { RuleListPageTitle } from './RuleListPageTitle';
function RuleList() { function RuleList() {
const [queryParams] = useURLSearchParams(); const [queryParams] = useURLSearchParams();
@ -86,7 +87,12 @@ export function RuleListActions() {
export default function RuleListPage() { export default function RuleListPage() {
return ( return (
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={<RuleListActions />}> <AlertingPageWrapper
navId="alert-list"
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={false}
actions={<RuleListActions />}
>
<RuleList /> <RuleList />
</AlertingPageWrapper> </AlertingPageWrapper>
); );

@ -0,0 +1,129 @@
import { render } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { reportInteraction } from '@grafana/runtime';
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(),
}));
// Mock window.location.reload
const mockReload = jest.fn();
Object.defineProperty(window, 'location', {
value: { reload: mockReload },
writable: true,
});
const ui = {
title: byRole('heading', { name: 'Alert rules' }),
enableV2Button: byRole('button', { name: 'Try out the new look!' }),
disableV2Button: byRole('button', { name: 'Go back to the old look' }),
};
// Helper function for rendering the component
function renderRuleListPageTitle() {
// Mock localStorage
const storage = new Map<string, string>();
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(<RuleListPageTitle title="Alert rules" />);
return {
...view,
storage,
};
}
describe('RuleListPageTitle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the title', () => {
renderRuleListPageTitle();
expect(ui.title.get()).toBeInTheDocument();
});
it('should not show v2 toggle when alertingListViewV2PreviewToggle feature flag is disabled', () => {
renderRuleListPageTitle();
expect(ui.enableV2Button.query()).not.toBeInTheDocument();
expect(ui.disableV2Button.query()).not.toBeInTheDocument();
});
describe('with alertingListViewV2PreviewToggle enabled and alertingListViewV2 disabled', () => {
testWithFeatureToggles(['alertingListViewV2PreviewToggle']);
it('should show enable v2 button', () => {
renderRuleListPageTitle();
expect(ui.enableV2Button.get()).toBeInTheDocument();
expect(ui.disableV2Button.query()).not.toBeInTheDocument();
expect(ui.enableV2Button.get()).toHaveAttribute('data-testid', 'alerting-list-view-toggle-v2');
});
it('should enable v2 and reload page when clicked on "Try out the new look!" button', async () => {
const { user, storage } = renderRuleListPageTitle();
await user.click(ui.enableV2Button.get());
expect(storage.get(featureTogglesKey)).toBe(`${toggleName}=true`);
expect(mockReload).toHaveBeenCalled();
});
it('should report interaction when enabling v2', async () => {
const { user } = renderRuleListPageTitle();
await user.click(ui.enableV2Button.get());
expect(reportInteraction).toHaveBeenCalledWith('alerting.list_view.v2.enabled');
});
});
describe('with alertingListViewV2PreviewToggle enabled and alertingListViewV2 enabled', () => {
testWithFeatureToggles(['alertingListViewV2PreviewToggle', 'alertingListViewV2']);
it('should show disable v2 button', () => {
renderRuleListPageTitle();
expect(ui.disableV2Button.get()).toBeInTheDocument();
expect(ui.enableV2Button.query()).not.toBeInTheDocument();
expect(ui.disableV2Button.get()).toHaveAttribute('data-testid', 'alerting-list-view-toggle-v1');
});
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`);
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(mockReload).toHaveBeenCalled();
});
it('should report interaction when disabling v2', async () => {
const { user, storage } = renderRuleListPageTitle();
storage.set(featureTogglesKey, `${toggleName}=true`);
await user.click(ui.disableV2Button.get());
expect(reportInteraction).toHaveBeenCalledWith('alerting.list_view.v2.disabled');
});
});
});

@ -0,0 +1,69 @@
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';
export function RuleListPageTitle({ title }: { title: string }) {
const shouldShowV2Toggle = config.featureToggles.alertingListViewV2PreviewToggle ?? false;
const { listViewV2Enabled, enableListViewV2, disableListViewV2 } = useV2AlertListViewToggle();
const toggleListView = () => {
if (listViewV2Enabled) {
disableListViewV2();
reportInteraction('alerting.list_view.v2.disabled');
} else {
enableListViewV2();
reportInteraction('alerting.list_view.v2.enabled');
}
window.location.reload();
};
const { text, ...configToUse }: ButtonProps & { text: string; 'data-testid': string } = listViewV2Enabled
? {
variant: 'secondary',
icon: undefined,
text: t('alerting.rule-list.toggle.go-back-to-old-look', 'Go back to the old look'),
'data-testid': 'alerting-list-view-toggle-v1',
}
: {
variant: 'primary',
icon: 'rocket',
text: t('alerting.rule-list.toggle.try-out-the-new-look', 'Try out the new look!'),
'data-testid': 'alerting-list-view-toggle-v2',
};
return (
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={2}>
<h1>{title}</h1>
{shouldShowV2Toggle && (
<div>
<Button size="sm" fill="outline" {...configToUse} onClick={toggleListView} className="fs-unmask">
{text}
</Button>
</div>
)}
</Stack>
);
}
function useV2AlertListViewToggle() {
const listViewV2Enabled = shouldUseAlertingListViewV2();
const enableListViewV2 = useCallback(() => {
setLocalStorageFeatureToggle('alertingListViewV2', true);
}, []);
const disableListViewV2 = useCallback(() => {
setLocalStorageFeatureToggle('alertingListViewV2', undefined);
}, []);
return {
listViewV2Enabled,
enableListViewV2,
disableListViewV2,
};
}

@ -2004,6 +2004,10 @@
"title": "Alert rules" "title": "Alert rules"
}, },
"rulerrule-loading-error": "Failed to load the rule", "rulerrule-loading-error": "Failed to load the rule",
"toggle": {
"go-back-to-old-look": "Go back to the old look",
"try-out-the-new-look": "Try out the new look!"
},
"unknown-rule-type": "Unknown rule type" "unknown-rule-type": "Unknown rule type"
}, },
"rule-list-errors": { "rule-list-errors": {

Loading…
Cancel
Save