diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4cac8f6dd81..7843dca6c87 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -425,6 +425,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /public/locales/enterprise/i18next.config.ts @grafana/grafana-frontend-platform /public/app/core/internationalization/ @grafana/grafana-frontend-platform /e2e/ @grafana/grafana-frontend-platform +/e2e-playwright/alerting-suite/ @grafana/alerting-frontend /e2e-playwright/cloud-plugins-suite/ @grafana/partner-datasources /e2e-playwright/dashboard-new-layouts/ @grafana/dashboards-squad /e2e-playwright/dashboard-cujs/ @grafana/dashboards-squad diff --git a/e2e-playwright/alerting-suite/saved-searches.spec.ts b/e2e-playwright/alerting-suite/saved-searches.spec.ts new file mode 100644 index 00000000000..28de805a5a1 --- /dev/null +++ b/e2e-playwright/alerting-suite/saved-searches.spec.ts @@ -0,0 +1,271 @@ +import { Page } from '@playwright/test'; + +import { test, expect } from '@grafana/plugin-e2e'; + +/** + * UI selectors for Saved Searches e2e tests. + * Each selector is a function that takes the page and returns a locator. + */ +const ui = { + // Main elements + savedSearchesButton: (page: Page) => page.getByRole('button', { name: /saved searches/i }), + dropdown: (page: Page) => page.getByRole('dialog', { name: /saved searches/i }), + searchInput: (page: Page) => page.getByTestId('search-query-input'), + + // Save functionality + saveButton: (page: Page) => page.getByRole('button', { name: /save current search/i }), + saveConfirmButton: (page: Page) => page.getByRole('button', { name: /^save$/i }), + saveNameInput: (page: Page) => page.getByPlaceholder(/enter a name/i), + + // Action menu + actionsButton: (page: Page) => page.getByRole('button', { name: /actions/i }), + renameMenuItem: (page: Page) => page.getByText(/rename/i), + deleteMenuItem: (page: Page) => page.getByText(/^delete$/i), + setAsDefaultMenuItem: (page: Page) => page.getByText(/set as default/i), + deleteConfirmButton: (page: Page) => page.getByRole('button', { name: /^delete$/i }), + + // Indicators + emptyState: (page: Page) => page.getByText(/no saved searches/i), + defaultIcon: (page: Page) => page.locator('[title="Default search"]'), + duplicateError: (page: Page) => page.getByText(/already exists/i), +}; + +/** + * Helper to clear saved searches storage. + * UserStorage uses localStorage as fallback, so we clear both potential keys. + */ +async function clearSavedSearches(page: Page) { + await page.evaluate(() => { + // Clear localStorage keys that might contain saved searches + // UserStorage stores under 'grafana.userstorage.alerting' pattern + const keysToRemove = Object.keys(localStorage).filter( + (key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage')) + ); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + + // Also clear session storage visited flag + const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting')); + sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key)); + }); +} + +test.describe( + 'Alert Rules - Saved Searches', + { + tag: ['@alerting'], + }, + () => { + test.beforeEach(async ({ page }) => { + // Clear any saved searches from previous tests before navigating + await page.goto('/alerting/list'); + await clearSavedSearches(page); + await page.reload(); + }); + + test.afterEach(async ({ page }) => { + // Clean up saved searches after each test + await clearSavedSearches(page); + }); + + test('should display Saved searches button', async ({ page }) => { + await expect(ui.savedSearchesButton(page)).toBeVisible(); + }); + + test('should open dropdown when clicking Saved searches button', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.dropdown(page)).toBeVisible(); + }); + + test('should show empty state when no saved searches exist', async ({ page }) => { + // Storage is cleared in beforeEach, so we should see empty state + await ui.savedSearchesButton(page).click(); + + await expect(ui.emptyState(page)).toBeVisible(); + }); + + test('should enable Save current search button when search query is entered', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + // Open saved searches + await ui.savedSearchesButton(page).click(); + + await expect(ui.saveButton(page)).toBeEnabled(); + }); + + test('should disable Save current search button when search query is empty', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.saveButton(page)).toBeDisabled(); + }); + + test('should save a new search', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + // Open saved searches + await ui.savedSearchesButton(page).click(); + + // Click save button + await ui.saveButton(page).click(); + + // Enter name and save + await ui.saveNameInput(page).fill('My Firing Rules'); + await ui.saveConfirmButton(page).click(); + + // Verify the saved search appears in the list + await expect(page.getByText('My Firing Rules')).toBeVisible(); + }); + + test('should show validation error for duplicate name', async ({ page }) => { + // First save a search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Duplicate Test'); + await ui.saveConfirmButton(page).click(); + + // Try to save another with the same name + await ui.saveButton(page).click(); + await ui.saveNameInput(page).fill('Duplicate Test'); + await ui.saveConfirmButton(page).click(); + + // Verify validation error + await expect(ui.duplicateError(page)).toBeVisible(); + }); + + test('should apply a saved search', async ({ page }) => { + // Create a saved search first + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Apply Test'); + await ui.saveConfirmButton(page).click(); + + // Clear the search + await ui.searchInput(page).clear(); + await ui.searchInput(page).press('Enter'); + + // Apply the saved search + await ui.savedSearchesButton(page).click(); + await page.getByRole('button', { name: /apply search.*apply test/i }).click(); + + // Verify the search input is updated + await expect(ui.searchInput(page)).toHaveValue('state:firing'); + }); + + test('should rename a saved search', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Original Name'); + await ui.saveConfirmButton(page).click(); + + // Open action menu and click rename + await ui.actionsButton(page).click(); + await ui.renameMenuItem(page).click(); + + // Enter new name + const renameInput = page.getByDisplayValue('Original Name'); + await renameInput.clear(); + await renameInput.fill('Renamed Search'); + await page.keyboard.press('Enter'); + + // Verify the name was updated + await expect(page.getByText('Renamed Search')).toBeVisible(); + await expect(page.getByText('Original Name')).not.toBeVisible(); + }); + + test('should delete a saved search', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('To Delete'); + await ui.saveConfirmButton(page).click(); + + // Verify it was saved + await expect(page.getByText('To Delete')).toBeVisible(); + + // Open action menu and click delete + await ui.actionsButton(page).click(); + await ui.deleteMenuItem(page).click(); + + // Confirm delete + await ui.deleteConfirmButton(page).click(); + + // Verify it was deleted + await expect(page.getByText('To Delete')).not.toBeVisible(); + }); + + test('should set a search as default', async ({ page }) => { + // Create a saved search + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + await ui.saveButton(page).click(); + + await ui.saveNameInput(page).fill('Default Test'); + await ui.saveConfirmButton(page).click(); + + // Set as default + await ui.actionsButton(page).click(); + await ui.setAsDefaultMenuItem(page).click(); + + // Verify the star icon appears (indicating default) + await expect(ui.defaultIcon(page)).toBeVisible(); + }); + + test('should close dropdown when pressing Escape', async ({ page }) => { + await ui.savedSearchesButton(page).click(); + + await expect(ui.dropdown(page)).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(ui.dropdown(page)).not.toBeVisible(); + }); + + test('should cancel save mode when pressing Escape', async ({ page }) => { + // Enter a search query + await ui.searchInput(page).fill('state:firing'); + await ui.searchInput(page).press('Enter'); + + await ui.savedSearchesButton(page).click(); + + // Start save mode + await ui.saveButton(page).click(); + + await expect(ui.saveNameInput(page)).toBeVisible(); + + // Press Escape to cancel + await page.keyboard.press('Escape'); + + // Verify we're back to list mode + await expect(ui.saveNameInput(page)).not.toBeVisible(); + await expect(ui.saveButton(page)).toBeVisible(); + }); + } +); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 19a8fbf2c44..981b10dfb1c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -535,6 +535,10 @@ export interface FeatureToggles { */ alertingListViewV2?: boolean; /** + * Enables saved searches for alert rules list + */ + alertingSavedSearches?: boolean; + /** * Disables the ability to send alerts to an external Alertmanager datasource. */ alertingDisableSendAlertsExternal?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7e876849dfe..d6f2bcbec2e 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -885,6 +885,13 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: true, }, + { + Name: "alertingSavedSearches", + Description: "Enables saved searches for alert rules list", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "alertingDisableSendAlertsExternal", Description: "Disables the ability to send alerts to an external Alertmanager datasource.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 510c05a815b..179568aa0c4 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -122,6 +122,7 @@ suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true +alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0db4a887a6a..42922ecf82d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -498,6 +498,19 @@ "codeowner": "@grafana/alerting-squad" } }, + { + "metadata": { + "name": "alertingSavedSearches", + "resourceVersion": "1765453147546", + "creationTimestamp": "2025-12-11T11:39:07Z" + }, + "spec": { + "description": "Enables saved searches for alert rules list", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } + }, { "metadata": { "name": "alertingTriage", diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 7ca1747a8c5..15fa8e11fd8 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -26,3 +26,8 @@ export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUse export const shouldUseFullyCompatibleBackendFilters = () => config.featureToggles.alertingUIUseFullyCompatBackendFilters ?? false; + +/** + * Saved searches feature - allows users to save and apply search queries on the Alert Rules page. + */ +export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false; diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 614c8235dd0..67053ade5f6 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -9,6 +9,7 @@ import { setupAlertmanagerStatusMapDefaultState, } from 'app/features/alerting/unified/mocks/server/entities/alertmanagers'; import { resetRoutingTreeMap } from 'app/features/alerting/unified/mocks/server/entities/k8s/routingtrees'; +import { resetUserStorage } from 'app/features/alerting/unified/mocks/server/handlers/userStorage'; import { DashboardDTO } from 'app/types/dashboard'; import { FolderDTO } from 'app/types/folders'; import { @@ -256,6 +257,7 @@ export function setupMswServer() { setupAlertmanagerConfigMapDefaultState(); setupAlertmanagerStatusMapDefaultState(); resetRoutingTreeMap(); + resetUserStorage(); }); return server; diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index 4b0b0ac7249..c3a9db4ac5c 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -19,6 +19,7 @@ import allPluginHandlers from 'app/features/alerting/unified/mocks/server/handle import provisioningHandlers from 'app/features/alerting/unified/mocks/server/handlers/provisioning'; import searchHandlers from 'app/features/alerting/unified/mocks/server/handlers/search'; import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers/silences'; +import userStorageHandlers from 'app/features/alerting/unified/mocks/server/handlers/userStorage'; /** * All alerting-specific handlers that are required across tests @@ -55,6 +56,7 @@ const allHandlers = [ ...datasourcesHandlers, ...evalHandlers, ...pluginsHandlers, + ...userStorageHandlers, ]; export default allHandlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts b/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts new file mode 100644 index 00000000000..b2a162ea898 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/userStorage.ts @@ -0,0 +1,171 @@ +import { HttpResponse, http } from 'msw'; + +import { config } from '@grafana/runtime'; + +/** + * UserStorage spec type matching the backend API response. + */ +interface UserStorageSpec { + data: { [key: string]: string }; +} + +/** + * In-memory storage for UserStorage mock data. + * This allows tests to set up and verify storage state. + */ +let userStorageData: Record = {}; + +/** + * Get the base URL for UserStorage API. + * Uses config.namespace which defaults to 'default' in tests. + */ +const getBaseUrl = () => `/apis/userstorage.grafana.app/v0alpha1/namespaces/${config.namespace}/user-storage`; + +/** + * Reset the in-memory storage. Call this in beforeEach to ensure clean test state. + */ +export function resetUserStorage(): void { + userStorageData = {}; +} + +/** + * Get the resource name for a given service, matching how UserStorage constructs it. + * This uses config.bootData.user to determine the user identifier. + * + * @param service - The service name (e.g., 'alerting') + * @returns The resource name in format `{service}:{userUID}` + * + * @example + * // In a test with config.bootData.user = { uid: '', id: 123 } + * const resourceName = getResourceName('alerting'); // 'alerting:123' + * + * // In a test with config.bootData.user = { uid: 'abc-123', id: 456 } + * const resourceName = getResourceName('alerting'); // 'alerting:abc-123' + */ +export function getResourceName(service: string): string { + const user = config.bootData?.user; + const userUID = user?.uid === '' || !user?.uid ? String(user?.id ?? 'anonymous') : user.uid; + return `${service}:${userUID}`; +} + +/** + * Convenience constant for the alerting service name. + * Use with getResourceName('alerting') or ALERTING_SERVICE directly. + */ +export const ALERTING_SERVICE = 'alerting'; + +/** + * Set up initial data in the UserStorage mock. + * @param resourceName - The resource name (e.g., 'alerting:123'). Use getResourceName() to construct this. + * @param key - The storage key + * @param value - The value to store + */ +export function setUserStorageItem(resourceName: string, key: string, value: string): void { + if (!userStorageData[resourceName]) { + userStorageData[resourceName] = { data: {} }; + } + userStorageData[resourceName].data[key] = value; +} + +/** + * Convenience function to set alerting storage items using the current config's user. + * This automatically constructs the resource name from config.bootData.user. + * + * @param key - The storage key (e.g., 'savedSearches') + * @param value - The value to store (will be stored as-is, caller should JSON.stringify if needed) + * + * @example + * // Set up saved searches for testing + * setAlertingStorageItem('savedSearches', JSON.stringify([{ id: '1', name: 'Test', query: 'state:firing', isDefault: false, createdAt: Date.now() }])); + */ +export function setAlertingStorageItem(key: string, value: string): void { + const resourceName = getResourceName(ALERTING_SERVICE); + setUserStorageItem(resourceName, key, value); +} + +/** + * Convenience function to get alerting storage items using the current config's user. + * + * @param key - The storage key (e.g., 'savedSearches') + * @returns The stored value or null if not found + */ +export function getAlertingStorageItem(key: string): string | null { + const resourceName = getResourceName(ALERTING_SERVICE); + return getUserStorageItem(resourceName, key); +} + +/** + * Get data from the UserStorage mock. + * @param resourceName - The resource name (e.g., 'alerting:123') + * @param key - The storage key + * @returns The stored value or null if not found + */ +export function getUserStorageItem(resourceName: string, key: string): string | null { + return userStorageData[resourceName]?.data[key] ?? null; +} + +/** + * Get the full storage spec for a resource. + * @param resourceName - The resource name + */ +export function getUserStorageSpec(resourceName: string): UserStorageSpec | null { + return userStorageData[resourceName] ?? null; +} + +/** + * MSW handler for GET UserStorage (retrieve stored data) + */ +const getUserStorageHandler = () => + http.get<{ resourceName: string }>(`${getBaseUrl()}/:resourceName`, ({ params }) => { + const spec = userStorageData[params.resourceName]; + + if (!spec) { + return HttpResponse.json({ message: 'Not found' }, { status: 404 }); + } + + return HttpResponse.json({ spec }); + }); + +/** + * MSW handler for POST UserStorage (create new storage) + */ +const createUserStorageHandler = () => + http.post(getBaseUrl(), async ({ request }) => { + const body = (await request.json()) as { + metadata: { name: string; labels: { user: string; service: string } }; + spec: UserStorageSpec; + }; + + const resourceName = body.metadata.name; + userStorageData[resourceName] = body.spec; + + return HttpResponse.json({ spec: body.spec }, { status: 201 }); + }); + +/** + * MSW handler for PATCH UserStorage (update existing storage) + */ +const patchUserStorageHandler = () => + http.patch<{ resourceName: string }>(`${getBaseUrl()}/:resourceName`, async ({ params, request }) => { + const body = (await request.json()) as { spec: UserStorageSpec }; + const resourceName = params.resourceName; + + if (!userStorageData[resourceName]) { + userStorageData[resourceName] = { data: {} }; + } + + // Merge the new data with existing data + userStorageData[resourceName].data = { + ...userStorageData[resourceName].data, + ...body.spec.data, + }; + + return HttpResponse.json({ spec: userStorageData[resourceName] }); + }); + +/** + * All UserStorage MSW handlers + */ +const handlers = [getUserStorageHandler(), createUserStorageHandler(), patchUserStorageHandler()]; + +export default handlers; diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index d4c445a2772..7985791cfa2 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -1,5 +1,5 @@ import { HttpResponse } from 'msw'; -import { render, testWithFeatureToggles } from 'test/test-utils'; +import { render, testWithFeatureToggles, waitFor } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { OrgRole } from '@grafana/data'; @@ -12,7 +12,8 @@ import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; -import RuleList, { RuleListActions } from './RuleList.v2'; +import RuleListPage, { RuleListActions } from './RuleList.v2'; +import { loadDefaultSavedSearch } from './filter/useSavedSearches'; // This tests only checks if proper components are rendered, so we mock them // Both FilterView and GroupedView are tested in their own tests @@ -24,6 +25,30 @@ jest.mock('./GroupedView', () => ({ GroupedView: () =>
Grouped View
, })); +jest.mock('./filter/useSavedSearches', () => ({ + ...jest.requireActual('./filter/useSavedSearches'), + loadDefaultSavedSearch: jest.fn(), + useSavedSearches: jest.fn(() => ({ + savedSearches: [], + isLoading: false, + saveSearch: jest.fn(), + renameSearch: jest.fn(), + deleteSearch: jest.fn(), + setDefaultSearch: jest.fn(), + })), +})); + +const loadDefaultSavedSearchMock = loadDefaultSavedSearch as jest.MockedFunction; + +beforeEach(() => { + loadDefaultSavedSearchMock.mockResolvedValue(null); + // Clear session storage to ensure clean state for each test + // This prevents the "visited" flag from affecting subsequent tests + sessionStorage.clear(); + // Set the visited flag for non-default-search tests to prevent the hook from trying to load + sessionStorage.setItem('grafana.alerting.ruleList.visited', 'true'); +}); + const ui = { filterView: byTestId('filter-view'), groupedView: byTestId('grouped-view'), @@ -45,16 +70,16 @@ setupMswServer(); alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' }); alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' }); -describe('RuleList v2', () => { +describe('RuleListPage v2', () => { it('should show grouped view by default', () => { - render(); + render(); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when invalid view parameter is provided', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?view=invalid'], }, @@ -65,35 +90,35 @@ describe('RuleList v2', () => { }); it('should show list view when "view=list" URL parameter is present', () => { - render(, { historyOptions: { initialEntries: ['/?view=list'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show grouped view when only group filter is applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } }); + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when only namespace filter is applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?search=namespace:global'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when both group and namespace filters are applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show list view when group and namespace filters are combined with other filter types', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global state:firing'] }, }); @@ -102,14 +127,14 @@ describe('RuleList v2', () => { }); it('should show grouped view when view parameter is empty', () => { - render(, { historyOptions: { initialEntries: ['/?view='] } }); + render(, { historyOptions: { initialEntries: ['/?view='] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); }); it('should show grouped view when search parameter is empty', () => { - render(, { historyOptions: { initialEntries: ['/?search='] } }); + render(, { historyOptions: { initialEntries: ['/?search='] } }); expect(ui.groupedView.get()).toBeInTheDocument(); expect(ui.filterView.query()).not.toBeInTheDocument(); @@ -125,28 +150,28 @@ describe('RuleList v2', () => { { filterType: 'ruleHealth', searchQuery: 'health:error' }, { filterType: 'contactPoint', searchQuery: 'contactPoint:slack' }, ])('should show list view when %s filter is applied', ({ filterType, searchQuery }) => { - render(, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } }); + render(, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with group filter', () => { - render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with namespace filter', () => { - render(, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } }); + render(, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); }); it('should show list view when "view=list" URL parameter is present with both group and namespace filters', () => { - render(, { + render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, }); @@ -342,10 +367,10 @@ describe('RuleListActions', () => { }); }); -describe('RuleList v2 - View switching', () => { +describe('RuleListPage v2 - View switching', () => { it('should preserve both group and namespace filters when switching from list view to grouped view', async () => { // Start with list view and both group and namespace filters - const { user } = render(, { + const { user } = render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, }); expect(ui.filterView.get()).toBeInTheDocument(); @@ -365,7 +390,7 @@ describe('RuleList v2 - View switching', () => { it('should clear all filters when switching from list view to grouped view with group, namespace and other filters', async () => { // Start with list view with all types of filters - const { user } = render(, { + const { user } = render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global state:firing rule:"test"'], }, @@ -385,3 +410,86 @@ describe('RuleList v2 - View switching', () => { expect(ui.modeSelector.list.query()).not.toBeChecked(); }); }); +describe('RuleListPage v2 - Default search auto-apply', () => { + // These tests verify that the default search is applied at the page level, + // BEFORE child components mount, preventing double API requests. + + testWithFeatureToggles({ enable: ['alertingListViewV2', 'alertingSavedSearches'] }); + + beforeEach(() => { + // Clear the visited flag so the hook detects this as a first visit + sessionStorage.removeItem('grafana.alerting.ruleList.visited'); + // Clear mock call history between tests + loadDefaultSavedSearchMock.mockClear(); + }); + + it('should apply default search before rendering child components', async () => { + const mockDefaultSearch = { + id: '1', + name: 'My Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }; + + // Mock loadDefaultSavedSearch to return a default search + loadDefaultSavedSearchMock.mockResolvedValue(mockDefaultSearch); + + render(); + + // Wait for loadDefaultSavedSearch to be called + await waitFor(() => { + expect(loadDefaultSavedSearchMock).toHaveBeenCalled(); + }); + + // Wait for the filter view to render with the applied search + await waitFor(() => { + expect(ui.filterView.get()).toBeInTheDocument(); + }); + + // Verify the search input shows the applied search query + expect(ui.searchInput.get()).toHaveValue('state:firing'); + }); + + it('should not apply default search when URL already has search parameter', async () => { + const mockDefaultSearch = { + id: '1', + name: 'My Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }; + + // loadDefaultSavedSearch should not be called when URL has search param + loadDefaultSavedSearchMock.mockResolvedValue(mockDefaultSearch); + + render(, { + historyOptions: { initialEntries: ['/?search=label:team=backend'] }, + }); + + // Wait for the component to render + await waitFor(() => { + expect(ui.searchInput.get()).toBeInTheDocument(); + }); + + // Should show the URL's search, not the default + expect(ui.searchInput.get()).toHaveValue('label:team=backend'); + + // Verify loadDefaultSavedSearch was not called because filters are already active + // The hook should not execute at all when hasActiveFilters is true + expect(loadDefaultSavedSearchMock).not.toHaveBeenCalled(); + }); + + it('should render normally when no default search exists', async () => { + loadDefaultSavedSearchMock.mockResolvedValue(null); + + render(); + + // Wait for the component to render after checking for default search + await waitFor(() => { + expect(ui.groupedView.get()).toBeInTheDocument(); + }); + + expect(ui.searchInput.get()).toHaveValue(''); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index e828b00fa16..284bd6ad757 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -18,6 +18,7 @@ import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; import { RuleListPageTitle } from './RuleListPageTitle'; import RulesFilter from './filter/RulesFilter'; +import { useApplyDefaultSearch } from './filter/useApplyDefaultSearch'; function RuleList() { const { filterState } = useRulesFilter(); @@ -117,14 +118,16 @@ export function RuleListActions() { } export default function RuleListPage() { + const { isApplying } = useApplyDefaultSearch(); + return ( } - isLoading={false} + isLoading={isApplying} actions={} > - + {!isApplying && } ); } diff --git a/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx b/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx new file mode 100644 index 00000000000..ae2db9e7264 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/InlineRenameInput.tsx @@ -0,0 +1,146 @@ +import { css } from '@emotion/css'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Box, IconButton, Input, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; + +import { SavedSearch, isValidationError, validateSearchName } from './savedSearchesSchema'; + +// ============================================================================ +// Inline Rename Input (compact input with icon buttons for renaming) +// ============================================================================ + +export interface InlineRenameInputProps { + initialValue: string; + /** Callback to save the renamed search. Throws ValidationError on validation failure. */ + onSave: (name: string) => Promise; + onCancel: () => void; + savedSearches: SavedSearch[]; + excludeId: string; +} + +interface FormValues { + name: string; +} + +export function InlineRenameInput({ + initialValue, + onSave, + onCancel, + savedSearches, + excludeId, +}: InlineRenameInputProps) { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + + const { + register, + handleSubmit, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { name: initialValue }, + }); + + // Focus and select input on mount using react-hook-form's setFocus + useEffect(() => { + setFocus('name', { shouldSelect: true }); + }, [setFocus]); + + const onSubmit = async (data: FormValues) => { + try { + await onSave(data.name.trim()); + } catch (error) { + // Check if it's a validation error (has field and message) + if (isValidationError(error)) { + // Validation errors are shown inline in the form + // This is handled by react-hook-form validation, but we keep this + // as a fallback for server-side validation errors + return; + } + // For generic save operation errors, show a notification + notifyApp.error( + t('alerting.saved-searches.error-rename-title', 'Failed to rename'), + t('alerting.saved-searches.error-rename-description', 'Your changes could not be saved. Please try again.') + ); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + +
+ + {/* Input area - flex=1 like the name area in list items */} + + { + const error = validateSearchName(value, savedSearches, excludeId); + return error ?? true; + }, + })} + onKeyDown={handleKeyDown} + placeholder={t('alerting.saved-searches.name-placeholder', 'Enter a name...')} + invalid={!!errors.name} + disabled={isSubmitting} + /> + + + {/* X icon - cancel */} + + + {/* Check icon - confirm rename */} + {/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */} + + +
+ {errors.name?.message && ( + + {errors.name.message} + + )} +
+ ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + successIcon: css({ + color: theme.colors.success.main, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx b/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx new file mode 100644 index 00000000000..9fd11151cd0 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/InlineSaveInput.tsx @@ -0,0 +1,139 @@ +import { css } from '@emotion/css'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Box, IconButton, Input, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; + +import { SavedSearch, isValidationError, validateSearchName } from './savedSearchesSchema'; + +// ============================================================================ +// Inline Save Input (compact input with icon buttons) +// ============================================================================ + +export interface InlineSaveInputProps { + /** Callback to save the search. Throws ValidationError on validation failure. */ + onSave: (name: string) => Promise; + onCancel: () => void; + savedSearches: SavedSearch[]; +} + +interface FormValues { + name: string; +} + +export function InlineSaveInput({ onSave, onCancel, savedSearches }: InlineSaveInputProps) { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + + const { + register, + handleSubmit, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { name: '' }, + }); + + // Focus input on mount using react-hook-form's setFocus + useEffect(() => { + setFocus('name'); + }, [setFocus]); + + const onSubmit = async (data: FormValues) => { + try { + await onSave(data.name.trim()); + } catch (error) { + // Check if it's a validation error (has field and message) + if (isValidationError(error)) { + // Validation errors are shown inline in the form + // This is handled by react-hook-form validation, but we keep this + // as a fallback for server-side validation errors + return; + } + // For generic save operation errors, show a notification + notifyApp.error( + t('alerting.saved-searches.error-save-title', 'Failed to save'), + t('alerting.saved-searches.error-save-description', 'Your changes could not be saved. Please try again.') + ); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + + {/* Match exact structure of SavedSearchItem: [flex-1 content] [icon] [icon] with gap={1} */} +
+ + {/* Input area - flex=1 like the name area in list items */} + + { + const error = validateSearchName(value, savedSearches); + return error ?? true; + }, + })} + onKeyDown={handleKeyDown} + placeholder={t('alerting.saved-searches.name-placeholder', 'Enter a name...')} + invalid={!!errors.name} + disabled={isSubmitting} + /> + + + {/* X icon - aligned with magnifying glass */} + + + {/* Check icon - aligned with action menu */} + {/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */} + + +
+ {errors.name?.message && ( + + {errors.name.message} + + )} +
+ ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + successIcon: css({ + color: theme.colors.success.main, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx index a8f6c91cbc3..8da5901a4b8 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from 'test/test-utils'; +import { render, screen, testWithFeatureToggles, waitFor } from 'test/test-utils'; import { byRole, byTestId } from 'testing-library-selector'; import { ComponentTypeWithExtensionMeta, PluginExtensionComponentMeta, PluginExtensionTypes } from '@grafana/data'; -import { config, locationService, setPluginComponentsHook } from '@grafana/runtime'; +import { locationService, setPluginComponentsHook } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { AccessControlAction } from 'app/types/accessControl'; @@ -15,6 +15,51 @@ import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; import RulesFilter from './RulesFilter'; +// Mock config for UserStorage (namespace and user must be set before UserStorage module loads) +// This allows the real UserStorage class to work with MSW handlers +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), // Silence analytics calls from useSavedSearches + getDataSourceSrv: () => ({ + getList: jest.fn().mockReturnValue([ + { name: 'Prometheus', uid: 'prometheus-uid' }, + { name: 'Loki', uid: 'loki-uid' }, + ]), + }), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + navTree: [], + user: { + uid: 'test-user-123', + id: 123, + isSignedIn: true, + }, + }, + }, +})); + +// Set up contextSrv.user.id for useSavedSearches session storage key. +// The hook uses this ID to create a per-user session storage key. +// Note: hasPermission must be mocked here because RulesFilter.v1.tsx calls it at module load time, +// before grantUserPermissions can set up the spy. grantUserPermissions still works for runtime checks. +jest.mock('app/core/services/context_srv', () => { + const actual = jest.requireActual('app/core/services/context_srv'); + return { + ...actual, + contextSrv: { + ...actual.contextSrv, + user: { + ...actual.contextSrv.user, + id: 123, + }, + hasPermission: jest.fn().mockReturnValue(true), + }, + }; +}); + // Grant permission before importing the component since permission check happens at module level grantUserPermissions([AccessControlAction.AlertingReceiversRead]); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -46,6 +91,7 @@ jest.mock('../../hooks/useFilteredRules', () => ({ const useRulesFilterMock = useRulesFilter as jest.MockedFunction; +// Set up MSW server with UserStorage handlers setupMswServer(); jest.spyOn(analytics, 'trackFilterButtonClick'); @@ -54,16 +100,6 @@ jest.spyOn(analytics, 'trackFilterButtonClearClick'); jest.spyOn(analytics, 'trackAlertRuleFilterEvent'); jest.spyOn(analytics, 'trackRulesSearchInputCleared'); -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getDataSourceSrv: () => ({ - getList: jest.fn().mockReturnValue([ - { name: 'Prometheus', uid: 'prometheus-uid' }, - { name: 'Loki', uid: 'loki-uid' }, - ]), - }), -})); - jest.mock('../../components/rules/MultipleDataSourcePicker', () => { const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { @@ -113,6 +149,8 @@ const ui = { beforeEach(() => { locationService.replace({ search: '' }); jest.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); mockFilterState = { ruleName: '', @@ -150,45 +188,46 @@ beforeEach(() => { }); describe('RulesFilter Feature Flag', () => { - const originalFeatureToggle = config.featureToggles.alertingFilterV2; + describe('with alertingFilterV2 enabled', () => { + testWithFeatureToggles({ enable: ['alertingFilterV2'] }); - afterEach(() => { - config.featureToggles.alertingFilterV2 = originalFeatureToggle; + it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { + render(); + + // Wait for suspense to resolve and check that the V2 filter button is present + await screen.findByRole('button', { name: 'Filter' }); + expect(ui.filterButton.get()).toBeInTheDocument(); + expect(ui.searchInput.get()).toBeInTheDocument(); + }); }); - it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { - config.featureToggles.alertingFilterV2 = true; + describe('with alertingFilterV2 disabled', () => { + testWithFeatureToggles({ disable: ['alertingFilterV2'] }); - render(); + it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { + render(); - // Wait for suspense to resolve and check that the V2 filter button is present - await screen.findByRole('button', { name: 'Filter' }); - expect(ui.filterButton.get()).toBeInTheDocument(); - expect(ui.searchInput.get()).toBeInTheDocument(); - }); + // Wait for suspense to resolve and check V1 structure + await screen.findByText('Search'); - it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { - config.featureToggles.alertingFilterV2 = false; + // V1 has search input but no V2-style filter button + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.query()).not.toBeInTheDocument(); - render(); - - // Wait for suspense to resolve and check V1 structure - await screen.findByText('Search'); - - // V1 has search input but no V2-style filter button - expect(ui.searchInput.get()).toBeInTheDocument(); - expect(ui.filterButton.query()).not.toBeInTheDocument(); - - // V1 has a help icon next to the search input - expect(screen.getByText('Search')).toBeInTheDocument(); + // V1 has a help icon next to the search input + expect(screen.getByText('Search')).toBeInTheDocument(); + }); }); }); describe('RulesFilterV2', () => { - it('Should render component without crashing', () => { + it('Should render component without crashing', async () => { render(); - expect(ui.searchInput.get()).toBeInTheDocument(); + // Wait for async hook operations (useSavedSearches) to complete + await waitFor(() => { + expect(ui.searchInput.get()).toBeInTheDocument(); + }); expect(ui.filterButton.get()).toBeInTheDocument(); }); @@ -429,4 +468,6 @@ describe('RulesFilterV2', () => { expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); }); }); + + // Auto-apply of default search is tested in RuleList.v2.test.tsx (behavior is in RuleListPage) }); diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx index 0091cdddec9..eb2e368ad80 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -39,10 +39,14 @@ import { useLabelOptions, useNamespaceAndGroupOptions, } from '../../components/rules/Filter/useRuleFilterAutocomplete'; +import { shouldUseSavedSearches } from '../../featureToggles'; import { useRulesFilter } from '../../hooks/useFilteredRules'; import { RuleHealth, RuleSource, getSearchFilterFromQuery } from '../../search/rulesSearchParser'; import { RulesFilterProps } from './RulesFilter'; +import { SavedSearches } from './SavedSearches'; +import { SavedSearch } from './savedSearchesSchema'; +import { trackSavedSearchApplied, useSavedSearches } from './useSavedSearches'; import { emptyAdvancedFilters, formAdvancedFiltersToRuleFilter, @@ -86,6 +90,19 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP const popupRef = useRef(null); const { pluginsFilterEnabled } = usePluginsFilterStatus(); + // Feature toggle for saved searches + const savedSearchesEnabled = shouldUseSavedSearches(); + + // Saved searches hook with UserStorage persistence + const { + savedSearches, + isLoading: savedSearchesLoading, + saveSearch, + renameSearch, + deleteSearch, + setDefaultSearch, + } = useSavedSearches(); + // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters const { control, setValue, handleSubmit } = useForm({ defaultValues: { @@ -97,6 +114,20 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP setValue('query', searchQuery); }, [searchQuery, setValue]); + // Apply saved search - triggers filtering (which updates search input and URL) + const handleApplySearch = useCallback( + (search: SavedSearch) => { + const parsedFilter = getSearchFilterFromQuery(search.query); + updateFilters(parsedFilter); + + // Track analytics + trackSavedSearchApplied(search); + }, + [updateFilters] + ); + + // Auto-apply of default search is handled in RuleListPage (before FilterView mounts) + const submitHandler: SubmitHandler = (values: SearchQueryForm) => { const parsedFilter = getSearchFilterFromQuery(values.query); trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter, filterVariant: 'v2' }); @@ -240,7 +271,21 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP {filterButtonLabel} - + {savedSearchesEnabled && ( + + )} + + + diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx new file mode 100644 index 00000000000..0fb92622b2d --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearchItem.tsx @@ -0,0 +1,226 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { Dropdown, Icon, IconButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { InlineRenameInput } from './InlineRenameInput'; +import { SavedSearch } from './savedSearchesSchema'; + +// ============================================================================ +// Saved Search Item +// ============================================================================ + +export interface SavedSearchItemProps { + search: SavedSearch; + isRenaming: boolean; + isDeleting: boolean; + isDisabled: boolean; + onApply: () => void; + onStartRename: () => void; + onCancelRename: () => void; + onRenameComplete: (newName: string) => Promise; + onStartDelete: () => void; + onCancelDelete: () => void; + onDeleteConfirm: () => Promise; + onSetDefault: () => void; + savedSearches: SavedSearch[]; + /** Portal root for the action menu - should be the outer dropdown container */ + menuPortalRoot?: HTMLElement | null; +} + +export function SavedSearchItem({ + search, + isRenaming, + isDeleting, + isDisabled, + onApply, + onStartRename, + onCancelRename, + onRenameComplete, + onStartDelete, + onCancelDelete, + onDeleteConfirm, + onSetDefault, + savedSearches, + menuPortalRoot, +}: SavedSearchItemProps) { + const styles = useStyles2(getStyles); + + // Rename mode - inline form matching the save form + // Stop propagation to prevent parent Dropdown from closing when interacting with form + if (isRenaming) { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()}> + +
+ ); + } + + // Delete confirm mode - inline with name visible + // Stop propagation to prevent parent Dropdown from closing when interacting with delete confirmation + if (isDeleting) { + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()}> + + {/* Name remains visible */} + + {search.name} + + + {/* X icon - cancel delete */} + + + {/* Trash icon - confirm delete */} + + +
+ ); + } + + // Default display mode + return ( +
+ + {/* Name and default indicator */} + + {search.name} + {search.isDefault && ( + + )} + + + {/* Apply button (magnifying glass) */} + + + {/* Action menu - stop propagation to prevent parent Dropdown from closing */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()}> + +
+
+
+ ); +} + +// ============================================================================ +// Action Menu (three-dot menu) +// ============================================================================ + +interface ActionMenuProps { + isDefault: boolean; + isDisabled: boolean; + onSetDefault: () => void; + onRename: () => void; + onDelete: () => void; + /** Portal root for the menu - renders inside the outer dropdown to prevent useDismiss issues */ + portalRoot?: HTMLElement | null; +} + +function ActionMenu({ isDefault, isDisabled, onSetDefault, onRename, onDelete, portalRoot }: ActionMenuProps) { + const menu = ( + + + + + + + ); + + return ( + + + + ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + item: css({ + padding: theme.spacing(0.5), + borderRadius: theme.shape.radius.default, + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), + defaultIcon: css({ + color: theme.colors.warning.main, + flexShrink: 0, + }), + deleteIcon: css({ + color: theme.colors.error.main, + }), + actionMenuWrapper: css({ + display: 'flex', + alignItems: 'center', + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md new file mode 100644 index 00000000000..8935cecaaa1 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.README.md @@ -0,0 +1,404 @@ +# Saved Searches Feature + +The Saved Searches feature allows users to save, manage, and quickly apply search queries on the Alert Rules page. + +## Overview + +Users can: + +- **Save** the current search query with a custom name +- **Apply** a saved search to instantly filter rules +- **Rename** existing saved searches +- **Delete** saved searches they no longer need +- **Set a default** search that auto-applies when navigating to the page + +## Components + +### `` + +The main component that renders a button and dropdown for managing saved searches. + +```tsx +import { SavedSearches } from './SavedSearches'; + +; +``` + +#### Props + +| Prop | Type | Required | Description | +| -------------------- | ------------------------------------------------------------------- | -------- | ------------------------------------------- | +| `savedSearches` | `SavedSearch[]` | Yes | Array of saved search objects | +| `currentSearchQuery` | `string` | Yes | The current search query in the input field | +| `onSave` | `(name: string, query: string) => Promise` | Yes | Called when user saves a new search | +| `onRename` | `(id: string, newName: string) => Promise` | Yes | Called when user renames a search | +| `onDelete` | `(id: string) => Promise` | Yes | Called when user deletes a search | +| `onApply` | `(search: SavedSearch) => void` | Yes | Called when user applies a search | +| `onSetDefault` | `(id: string \| null) => Promise` | Yes | Called when user sets/clears default | + +#### Types + +```typescript +interface SavedSearch { + /** Unique identifier */ + id: string; + /** User-provided name */ + name: string; + /** The search query string */ + query: string; + /** Whether this is the default search */ + isDefault: boolean; + /** Unix timestamp of creation */ + createdAt: number; +} + +interface ValidationError { + /** The field with the error */ + field: string; + /** Error message to display */ + message: string; +} +``` + +### `useSavedSearches()` Hook + +A custom hook that manages saved searches with UserStorage persistence. + +```tsx +import { useSavedSearches, trackSavedSearchApplied } from './useSavedSearches'; + +const { savedSearches, isLoading, saveSearch, renameSearch, deleteSearch, setDefaultSearch, getAutoApplySearch } = + useSavedSearches(); + +// Track when a search is applied +const handleApply = (search: SavedSearch) => { + applySearchToFilter(search.query); + trackSavedSearchApplied(search); +}; +``` + +#### Return Value + +| Property | Type | Description | +| -------------------- | --------------------------------------------------- | ------------------------------------- | +| `savedSearches` | `SavedSearch[]` | Current list of saved searches | +| `isLoading` | `boolean` | Whether initial load is in progress | +| `saveSearch` | `(name, query) => Promise` | Save a new search | +| `renameSearch` | `(id, newName) => Promise` | Rename an existing search | +| `deleteSearch` | `(id) => Promise` | Delete a search | +| `setDefaultSearch` | `(id \| null) => Promise` | Set or clear the default search | +| `getAutoApplySearch` | `() => SavedSearch \| null` | Get the default search for auto-apply | + +## Integration + +### Basic Integration + +```tsx +import { SavedSearches, SavedSearch } from './SavedSearches'; +import { useSavedSearches, trackSavedSearchApplied } from './useSavedSearches'; + +function MyFilterComponent() { + const { filterState, updateFilters } = useMyFilter(); + + const { savedSearches, saveSearch, renameSearch, deleteSearch, setDefaultSearch, getAutoApplySearch } = + useSavedSearches(); + + // Handle applying a saved search + const handleApply = useCallback( + (search: SavedSearch) => { + // Update your filter state with the saved query + updateFilters(parseQuery(search.query)); + + // Track analytics + trackSavedSearchApplied(search); + }, + [updateFilters] + ); + + // Auto-apply default search on navigation + useEffect(() => { + const defaultSearch = getAutoApplySearch(); + if (defaultSearch) { + handleApply(defaultSearch); + } + }, [getAutoApplySearch, handleApply]); + + return ( + + ); +} +``` + +### Feature Toggle + +The feature is gated behind the `alertingSavedSearches` feature toggle: + +```tsx +import { shouldUseSavedSearches } from '../../featureToggles'; + +function MyComponent() { + const savedSearchesEnabled = shouldUseSavedSearches(); + + return ( + <> + {savedSearchesEnabled && } + + ); +} +``` + +To enable during development, set in Grafana config: + +```ini +[feature_toggles] +alertingSavedSearches = true +``` + +## Behavior + +### Dropdown States + +1. **List Mode** (default) + - Shows saved searches sorted: default first, then alphabetical + - Shows "Save current search" button when `currentSearchQuery` is non-empty + - Empty state when no saved searches exist + +2. **Save Mode** + - Name input with validation + - Save/Cancel buttons + - Triggered by clicking "Save current search" + +3. **Rename Mode** (per-item) + - Inline editing of search name + - Confirm with Enter, cancel with Escape + +4. **Delete Confirmation** (per-item) + - Inline confirmation prompt + - Delete/Cancel buttons + +### Validation Rules + +| Rule | Message | +| ------------------------------ | ---------------------------------------------- | +| Name required | "Name is required" | +| Max length 64 | "Name must be 64 characters or less" | +| Unique name (case-insensitive) | "A saved search with this name already exists" | + +### Auto-Apply Default Search + +The default search auto-applies when: + +1. User **navigates** to the Alert Rules page (not on refresh) +2. No search query is present in the URL +3. A default search is configured + +This is tracked via `sessionStorage` to distinguish navigation from refresh. + +### Persistence + +Saved searches are stored using `UserStorage`: + +- **Backend API**: `/apis/userstorage.grafana.app/v0alpha1/namespaces/{namespace}/user-storage` +- **Fallback**: `localStorage` (when user not signed in or API fails) +- **Storage key**: `alerting.savedSearches` + +## Analytics + +The feature tracks the following events via `reportInteraction`: + +| Event | Properties | When | +| ------------------------------------------- | -------------------------- | -------------------- | +| `grafana_alerting_saved_search_save` | `hasDefault`, `totalCount` | Search saved | +| `grafana_alerting_saved_search_apply` | `isDefault` | Search applied | +| `grafana_alerting_saved_search_delete` | - | Search deleted | +| `grafana_alerting_saved_search_rename` | - | Search renamed | +| `grafana_alerting_saved_search_set_default` | `action: 'set' \| 'clear'` | Default changed | +| `grafana_alerting_saved_search_auto_apply` | - | Default auto-applied | + +## Testing + +### Component Tests + +Location: `SavedSearches.test.tsx` + +```bash +yarn test SavedSearches.test.tsx +``` + +Test categories: + +- **Rendering**: Button, dropdown, list sorting, empty state +- **Save functionality**: Validation, errors, success flow +- **Apply functionality**: Click handling, dropdown close +- **Action menu**: Set default, rename, delete options +- **Delete confirmation**: Confirm/cancel flows +- **Keyboard navigation**: Escape key handling +- **Edge cases**: Empty queries, whitespace trimming + +### Hook Tests + +Location: `useSavedSearches.test.tsx` + +```bash +yarn test useSavedSearches.test.tsx +``` + +Test categories: + +- **Initial loading**: Loading state, storage load, empty storage +- **saveSearch**: New search, duplicate detection, analytics +- **renameSearch**: Rename flow, duplicate detection +- **deleteSearch**: Delete flow, analytics +- **setDefaultSearch**: Set/clear default, analytics +- **getAutoApplySearch**: Navigation detection, URL check +- **Error handling**: Storage errors, notifications + +### Mocking UserStorage API with MSW + +The hook and component tests use MSW to mock the UserStorage API endpoints. +The handlers are defined in `mocks/server/handlers/userStorage.ts` and included via `setupMswServer()`: + +```typescript +import { setupMswServer, setAlertingStorageItem, getAlertingStorageItem } from 'app/features/alerting/unified/mockApi'; + +// Set up MSW server with UserStorage handlers (call at module level) +setupMswServer(); + +// In tests, use helper functions to set up storage data: +it('should load saved searches', async () => { + setAlertingStorageItem('savedSearches', JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual(mockSavedSearches); +}); + +// Verify persisted data: +it('should save a new search', async () => { + // ... perform save action ... + + const storedData = getAlertingStorageItem('savedSearches'); + expect(storedData).toContain('"name":"New Search"'); +}); +``` + +**Important**: Tests must mock `config.namespace` and `config.bootData.user` before imports, +so that `UserStorage` constructs the correct API URLs: + +```typescript +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + user: { uid: 'test-user-123', id: 123, isSignedIn: true }, + }, + }, +})); +``` + +### Verifying Notifications in UI + +Tests verify error notifications by rendering the `AppNotificationList` component: + +```typescript +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { getWrapper, screen } from 'test/test-utils'; + +function createWrapper() { + const Wrapper = getWrapper({ renderWithRouter: true }); + return function WrapperWithNotifications({ children }) { + return ( + + + {children} + + ); + }; +} + +// In tests (e.g., malformed JSON triggers error notification): +it('should handle malformed JSON gracefully', async () => { + setAlertingStorageItem('savedSearches', 'not valid json'); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(await screen.findByText(/failed to load saved searches/i)).toBeInTheDocument(); +}); +``` + +## Accessibility + +- Dropdown has `role="dialog"` for screen readers +- Action menu uses `@grafana/ui` `Dropdown` and `Menu` components +- Keyboard support: + - `Escape`: Close dropdown or cancel current operation + - `Tab`: Navigate through interactive elements + - `Enter`: Confirm inputs + +## File Structure + +``` +public/app/features/alerting/unified/rule-list/filter/ +├── SavedSearches.tsx # Main component +├── SavedSearches.test.tsx # Component tests +├── SavedSearches.README.md # This documentation +├── useSavedSearches.ts # Custom hook with persistence +└── useSavedSearches.test.tsx # Hook tests +``` + +## Dependencies + +- `@grafana/ui`: Button, Dropdown, Menu, Icon, Input, Stack, Box, Spinner, PopupCard +- `@grafana/i18n`: Trans, t (internationalization) +- `@grafana/runtime`: reportInteraction +- `@grafana/runtime/internal`: UserStorage +- `@emotion/css`: Styling via useStyles2 + +## E2E Tests + +Location: `e2e-playwright/alerting-suite/saved-searches.spec.ts` + +```bash +yarn e2e:playwright --grep "saved-searches" +``` + +Test scenarios: + +- Display Saved searches button +- Open/close dropdown +- Empty state +- Save current search (enabled/disabled) +- Create new saved search +- Validation errors (duplicate name) +- Apply saved search +- Rename saved search +- Delete saved search +- Set as default +- Keyboard navigation (Escape to close/cancel) diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx new file mode 100644 index 00000000000..7e3604c5eec --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.test.tsx @@ -0,0 +1,348 @@ +import { render, screen, waitFor } from 'test/test-utils'; +import { byPlaceholderText, byRole, byText } from 'testing-library-selector'; + +import { SavedSearches } from './SavedSearches'; +import { SavedSearch } from './savedSearchesSchema'; + +/** + * UI selectors for SavedSearches component tests. + * Using testing-library-selector for reusable, consistent selectors. + */ +const ui = { + // Main trigger button + savedSearchesButton: byRole('button', { name: /saved searches/i }), + // Dropdown dialog + dropdown: byRole('dialog'), + // Save functionality + saveButton: byRole('button', { name: /save current search/i }), + saveConfirmButton: byRole('button', { name: /save$/i }), + saveInput: byPlaceholderText(/enter a name/i), + // Action buttons + cancelButton: byRole('button', { name: /cancel/i }), + applyButtons: byRole('button', { name: /apply this search/i }), + actionMenuButtons: byRole('button', { name: /actions/i }), + deleteButton: byRole('button', { name: /delete/i }), + // Menu items (using byRole for proper accessibility testing) + setAsDefaultMenuItem: byRole('menuitem', { name: /set as default/i }), + removeDefaultMenuItem: byRole('menuitem', { name: /remove default/i }), + renameMenuItem: byRole('menuitem', { name: /rename/i }), + deleteMenuItem: byRole('menuitem', { name: /^delete$/i }), + // Messages + emptyStateMessage: byText(/no saved searches/i), + nameRequiredError: byText(/name is required/i), + duplicateNameError: byText(/a saved search with this name already exists/i), +}; + +// Mock data is ordered as it will appear after sorting by useSavedSearches: +// default search first, then alphabetically by name +const mockSavedSearches: SavedSearch[] = [ + { + id: '2', + name: 'Default Search', + query: 'label:team=A', + isDefault: true, + createdAt: Date.now() - 2000, + }, + { + id: '3', + name: 'Critical Alerts', + query: 'label:severity=critical state:firing', + isDefault: false, + createdAt: Date.now() - 3000, + }, + { + id: '1', + name: 'My Firing Rules', + query: 'state:firing', + isDefault: false, + createdAt: Date.now() - 1000, + }, +]; + +const defaultProps = { + savedSearches: mockSavedSearches, + currentSearchQuery: '', + onSave: jest.fn(), + onRename: jest.fn(), + onDelete: jest.fn(), + onApply: jest.fn(), + onSetDefault: jest.fn(), +}; + +describe('SavedSearches', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Displaying saved searches', () => { + it('shows empty state when no saved searches exist', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + expect(ui.emptyStateMessage.get()).toBeInTheDocument(); + }); + + it('displays saved searches with default search marked with star icon', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + // Verify searches are displayed + const applyButtons = await ui.applyButtons.findAll(); + expect(applyButtons).toHaveLength(3); + + // Verify the default search has a star icon + expect(screen.getByText('Default Search')).toBeInTheDocument(); + expect(screen.getByTitle('Default search')).toBeInTheDocument(); + }); + }); + + describe('Saving a search', () => { + it('saves current search with the provided name', async () => { + defaultProps.onSave.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), 'My New Search'); + await user.click(ui.saveConfirmButton.get()); + + expect(defaultProps.onSave).toHaveBeenCalledWith('My New Search', 'state:pending'); + }); + + it('disables save button when currentSearchQuery is empty', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + // Use findByText + closest because findByRole fails to find disabled Grafana Button + // (due to aria-disabled="false" + disabled="" attribute mismatch in Grafana UI) + const saveButtonText = await screen.findByText(/save current search/i); + // eslint-disable-next-line testing-library/no-node-access + const saveButton = saveButtonText.closest('button'); + expect(saveButton).toBeDisabled(); + }); + + it('shows validation error when name is empty', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.click(await ui.saveConfirmButton.find()); + + expect(await ui.nameRequiredError.find()).toBeInTheDocument(); + }); + + it('shows validation error for duplicate name', async () => { + defaultProps.onSave.mockResolvedValue({ + field: 'name', + message: 'A saved search with this name already exists', + }); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), 'My Firing Rules'); + await user.click(ui.saveConfirmButton.get()); + + expect(await ui.duplicateNameError.find()).toBeInTheDocument(); + }); + + it('trims whitespace from search name before saving', async () => { + defaultProps.onSave.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.type(await ui.saveInput.find(), ' My Search '); + await user.click(ui.saveConfirmButton.get()); + + expect(defaultProps.onSave).toHaveBeenCalledWith('My Search', 'state:pending'); + }); + + it('cancels save when cancel button is clicked', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + await user.click(await ui.cancelButton.find()); + + expect(defaultProps.onSave).not.toHaveBeenCalled(); + expect(ui.saveInput.query()).not.toBeInTheDocument(); + }); + }); + + describe('Applying a search', () => { + it('applies the selected search and closes dropdown', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const applyButtons = await ui.applyButtons.findAll(); + // Click the apply button for "My Firing Rules" (third in list: Default, Critical, My Firing) + await user.click(applyButtons[2]); + + expect(defaultProps.onApply).toHaveBeenCalledWith( + expect.objectContaining({ + id: '1', + name: 'My Firing Rules', + query: 'state:firing', + }) + ); + + await waitFor(() => { + expect(ui.dropdown.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Setting default search', () => { + it('sets a search as default', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + // Use a non-default search's menu (second item: "Critical Alerts") + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[1]); + await user.click(await ui.setAsDefaultMenuItem.find()); + + expect(defaultProps.onSetDefault).toHaveBeenCalledWith('3'); + }); + + it('removes default from a search', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + // Default Search is first item (index 0) + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.removeDefaultMenuItem.find()); + + expect(defaultProps.onSetDefault).toHaveBeenCalledWith(null); + }); + }); + + describe('Renaming a search', () => { + it('renames a search successfully', async () => { + defaultProps.onRename.mockResolvedValue(undefined); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.renameMenuItem.find()); + + const input = await screen.findByDisplayValue('Default Search'); + await user.clear(input); + await user.type(input, 'Renamed Search'); + await user.keyboard('{Enter}'); + + expect(defaultProps.onRename).toHaveBeenCalledWith('2', 'Renamed Search'); + }); + + it('shows validation error for duplicate name when renaming', async () => { + defaultProps.onRename.mockResolvedValue({ + field: 'name', + message: 'A saved search with this name already exists', + }); + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.renameMenuItem.find()); + + const input = await screen.findByDisplayValue('Default Search'); + await user.clear(input); + await user.type(input, 'My Firing Rules'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(ui.duplicateNameError.get()).toBeInTheDocument(); + }); + }); + }); + + describe('Deleting a search', () => { + it('deletes a search after confirmation', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + // Delete the first item (Default Search, id: '2') + await user.click(menuButtons[0]); + await user.click(await ui.deleteMenuItem.find()); + + // Confirm delete + const deleteButtons = await ui.deleteButton.findAll(); + await user.click(deleteButtons[deleteButtons.length - 1]); + + expect(defaultProps.onDelete).toHaveBeenCalledWith('2'); + }); + + it('cancels delete when cancel is clicked', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + const menuButtons = await ui.actionMenuButtons.findAll(); + await user.click(menuButtons[0]); + await user.click(await ui.deleteMenuItem.find()); + + await user.click(await ui.cancelButton.find()); + + expect(defaultProps.onDelete).not.toHaveBeenCalled(); + expect(screen.getByText('Default Search')).toBeInTheDocument(); + }); + }); + + describe('Keyboard navigation', () => { + it('closes dropdown when Escape is pressed', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + expect(await ui.dropdown.find()).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(ui.dropdown.query()).not.toBeInTheDocument(); + }); + }); + + it('cancels save mode when Escape is pressed without closing dropdown', async () => { + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + await user.click(await ui.saveButton.find()); + expect(await ui.saveInput.find()).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(ui.saveInput.query()).not.toBeInTheDocument(); + }); + // Dropdown should still be open + expect(ui.dropdown.get()).toBeInTheDocument(); + }); + }); + + describe('Edge cases', () => { + it('handles empty search query display gracefully', async () => { + const searchesWithEmptyQuery: SavedSearch[] = [ + { + id: '1', + name: 'Empty Search', + query: '', + isDefault: false, + createdAt: Date.now(), + }, + ]; + + const { user } = render(); + + await user.click(ui.savedSearchesButton.get()); + + expect(await screen.findByText('Empty Search')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx new file mode 100644 index 00000000000..0d4391642f0 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/SavedSearches.tsx @@ -0,0 +1,556 @@ +/** + * SavedSearches Component + * + * Allows users to save, manage, and quickly apply search queries on the Alert Rules page. + * + * ## Features + * - Save current search query with a custom name + * - Mark one search as "default" (auto-applied on navigation) + * - Rename, delete, and apply saved searches + * - Alphabetical sorting with default search pinned first + * + * ## Props + * @param savedSearches - Array of saved search objects + * @param currentSearchQuery - The current search query string from the filter state + * @param onSave - Callback to save a new search. Throws ValidationError on failure. + * @param onRename - Callback to rename an existing search. Throws ValidationError on failure. + * @param onDelete - Callback to delete a search + * @param onApply - Callback when a saved search is applied + * @param onSetDefault - Callback to set/unset default search (pass null to unset) + * @param disabled - Disables all interactions + * @param className - Additional CSS class name + * + * ## Internal States + * - Dropdown open/closed + * - Save mode (inputting new search name) + * - Rename mode (editing existing search name, by item ID) + * - Delete confirm mode (confirming deletion, by item ID) + * + * ## Accessibility + * - Uses role="dialog" for the dropdown panel + * - Basic keyboard navigation (Escape to close, Tab to navigate) + * - Focus moves to "Save current search" button when dropdown opens + * + * @example + * ```tsx + * + * ``` + */ + +import { css } from '@emotion/css'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Box, Button, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; + +import { PopupCard } from '../../components/HoverCard'; + +import { InlineSaveInput } from './InlineSaveInput'; +import { SavedSearchItem } from './SavedSearchItem'; +import { SavedSearch } from './savedSearchesSchema'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface SavedSearchesProps { + /** Array of saved search objects */ + savedSearches: SavedSearch[]; + /** The current search query string from the filter state */ + currentSearchQuery: string; + /** Callback to save a new search. Throws ValidationError on failure. */ + onSave: (name: string, query: string) => Promise; + /** Callback to rename an existing search. Throws ValidationError on failure. */ + onRename: (id: string, newName: string) => Promise; + /** Callback to delete a search */ + onDelete: (id: string) => Promise; + /** Callback when a saved search is applied */ + onApply: (search: SavedSearch) => void; + /** Callback to set/unset default search. Pass null to remove default. */ + onSetDefault: (id: string | null) => Promise; + /** Whether saved searches are still loading from storage */ + isLoading?: boolean; + /** Additional CSS class name */ + className?: string; +} + +// ============================================================================ +// State Management (Reducer) +// ============================================================================ + +// Active action type - represents the current action in progress +type ActiveAction = 'idle' | { type: 'saving' } | { type: 'renaming'; id: string } | { type: 'deleting'; id: string }; + +// Component state +interface DropdownState { + isOpen: boolean; + activeAction: ActiveAction; +} + +// Action types for the reducer +type DropdownAction = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { type: 'SET_VISIBLE'; visible: boolean } + | { type: 'START_SAVE' } + | { type: 'START_RENAME'; id: string } + | { type: 'START_DELETE'; id: string } + | { type: 'CANCEL_ACTION' } + | { type: 'COMPLETE_ACTION' } + | { type: 'APPLY_AND_CLOSE' }; + +const initialState: DropdownState = { + isOpen: false, + activeAction: 'idle', +}; + +/** + * Reducer for managing dropdown state and active actions. + * Centralizes all state transitions for easier reasoning and testing. + */ +function dropdownReducer(state: DropdownState, action: DropdownAction): DropdownState { + switch (action.type) { + case 'OPEN': + return { ...state, isOpen: true }; + + case 'CLOSE': + // Reset action when closing + return { isOpen: false, activeAction: 'idle' }; + + case 'SET_VISIBLE': + // When visibility changes, reset action if closing + return action.visible ? { ...state, isOpen: true } : { isOpen: false, activeAction: 'idle' }; + + case 'START_SAVE': + // Only start save if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'saving' } } : state; + + case 'START_RENAME': + // Only start rename if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'renaming', id: action.id } } : state; + + case 'START_DELETE': + // Only start delete if no action is active + return state.activeAction === 'idle' ? { ...state, activeAction: { type: 'deleting', id: action.id } } : state; + + case 'CANCEL_ACTION': + case 'COMPLETE_ACTION': + // Return to idle state + return { ...state, activeAction: 'idle' }; + + case 'APPLY_AND_CLOSE': + // Only apply if no action is active, then close + return state.activeAction === 'idle' ? { isOpen: false, activeAction: 'idle' } : state; + + default: + return state; + } +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function SavedSearches({ + savedSearches, + currentSearchQuery, + onSave, + onRename, + onDelete, + onApply, + onSetDefault, + isLoading = false, + className, +}: SavedSearchesProps) { + const styles = useStyles2(getStyles); + + // Centralized state management via reducer + const [state, dispatch] = useReducer(dropdownReducer, initialState); + const { isOpen, activeAction } = state; + + // Refs + const saveButtonRef = useRef(null); + const dialogRef = useRef(null); + + // Focus dialog when dropdown opens to enable keyboard navigation + useEffect(() => { + if (isOpen && activeAction === 'idle') { + // Small delay to ensure the dropdown is rendered + const timer = setTimeout(() => { + // Focus the dialog to capture keyboard events (like Escape) + // We focus the dialog instead of the save button because the button may be disabled + dialogRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + return undefined; + }, [isOpen, activeAction]); + + // Handle click outside to close dropdown - excludes portal elements (like action menu) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isOpen && dialogRef.current && event.target instanceof Node && !dialogRef.current.contains(event.target)) { + // Check if click is on a portal element (action menu dropdown) + if (event.target instanceof Element) { + const isPortalClick = + event.target.closest('[data-popper-placement]') || event.target.closest('[role="menu"]'); + + if (!isPortalClick) { + dispatch({ type: 'CLOSE' }); + } + } else { + dispatch({ type: 'CLOSE' }); + } + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Handle Escape key: cancel active action first, or close dropdown if no action is active + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isOpen) { + if (activeAction !== 'idle') { + dispatch({ type: 'CANCEL_ACTION' }); + } else { + dispatch({ type: 'CLOSE' }); + } + } + }; + + document.addEventListener('keydown', handleEscapeKey); + return () => document.removeEventListener('keydown', handleEscapeKey); + }, [isOpen, activeAction]); + + // Handlers + const handleToggle = useCallback(() => { + dispatch({ type: isOpen ? 'CLOSE' : 'OPEN' }); + }, [isOpen]); + + const handleClose = useCallback(() => { + dispatch({ type: 'CLOSE' }); + }, []); + + const handleStartSave = useCallback(() => { + dispatch({ type: 'START_SAVE' }); + }, []); + + const handleCancelSave = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleSaveComplete = useCallback( + async (name: string): Promise => { + await onSave(name, currentSearchQuery); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onSave, currentSearchQuery] + ); + + const handleStartRename = useCallback((id: string) => { + dispatch({ type: 'START_RENAME', id }); + }, []); + + const handleCancelRename = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleRenameComplete = useCallback( + async (id: string, newName: string): Promise => { + await onRename(id, newName); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onRename] + ); + + const handleStartDelete = useCallback((id: string) => { + dispatch({ type: 'START_DELETE', id }); + }, []); + + const handleCancelDelete = useCallback(() => { + dispatch({ type: 'CANCEL_ACTION' }); + }, []); + + const handleDeleteConfirm = useCallback( + async (id: string) => { + await onDelete(id); + dispatch({ type: 'COMPLETE_ACTION' }); + }, + [onDelete] + ); + + const handleApply = useCallback( + (search: SavedSearch) => { + // Only allow apply when no action is active (handled by reducer) + if (activeAction !== 'idle') { + return; + } + onApply(search); + dispatch({ type: 'APPLY_AND_CLOSE' }); + }, + [onApply, activeAction] + ); + + const handleSetDefault = useCallback( + async (id: string | null) => { + // Only allow set default when no action is active + if (activeAction !== 'idle') { + return; + } + await onSetDefault(id); + }, + [onSetDefault, activeAction] + ); + + const buttonLabel = t('alerting.saved-searches.button-label', 'Saved searches'); + const hasSearches = savedSearches.length > 0; + const canSave = currentSearchQuery.trim().length > 0; + + const content = ( +
+ +
+ ); + + return ( + + + + ); +} + +// ============================================================================ +// List Mode (shows saved searches or empty state) +// ============================================================================ + +interface ListModeProps { + searches: SavedSearch[]; + hasSearches: boolean; + canSave: boolean; + activeAction: ActiveAction; + saveButtonRef: React.RefObject; + isLoading: boolean; + onStartSave: () => void; + /** Callback to complete save. Throws ValidationError on validation failure. */ + onSaveComplete: (name: string) => Promise; + onCancelSave: () => void; + onApply: (search: SavedSearch) => void; + onStartRename: (id: string) => void; + onCancelRename: () => void; + /** Callback to complete rename. Throws ValidationError on validation failure. */ + onRenameComplete: (id: string, newName: string) => Promise; + onStartDelete: (id: string) => void; + onCancelDelete: () => void; + onDeleteConfirm: (id: string) => Promise; + onSetDefault: (id: string | null) => Promise; + savedSearches: SavedSearch[]; + /** Portal root for action menus - renders inside the dropdown to prevent useDismiss issues */ + menuPortalRoot: HTMLElement | null; +} + +function ListMode({ + searches, + hasSearches, + canSave, + activeAction, + saveButtonRef, + isLoading, + onStartSave, + onSaveComplete, + onCancelSave, + onApply, + onStartRename, + onCancelRename, + onRenameComplete, + onStartDelete, + onCancelDelete, + onDeleteConfirm, + onSetDefault, + savedSearches, + menuPortalRoot, +}: ListModeProps) { + const styles = useStyles2(getStyles); + + // Derived states from activeAction + const isSaveMode = typeof activeAction === 'object' && activeAction.type === 'saving'; + const isActionActive = activeAction !== 'idle'; + + // Show loading state + if (isLoading) { + return ( + + + + ); + } + + return ( + + {/* Save current search - button or inline input */} + {/* Stop propagation to prevent Dropdown from closing when interacting with save form */} + {isSaveMode ? ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + +
+ ) : ( + + + + )} + + {/* Empty state or list */} + {!hasSearches ? ( + + ) : ( + +
+ {searches.map((search) => { + const isRenaming = + typeof activeAction === 'object' && activeAction.type === 'renaming' && activeAction.id === search.id; + const isDeleting = + typeof activeAction === 'object' && activeAction.type === 'deleting' && activeAction.id === search.id; + // Item is disabled if any action is active and this item is not the one being acted upon + const isItemDisabled = isActionActive && !isRenaming && !isDeleting; + + return ( + onApply(search)} + onStartRename={() => onStartRename(search.id)} + onCancelRename={onCancelRename} + onRenameComplete={(newName) => onRenameComplete(search.id, newName)} + onStartDelete={() => onStartDelete(search.id)} + onCancelDelete={onCancelDelete} + onDeleteConfirm={() => onDeleteConfirm(search.id)} + onSetDefault={() => onSetDefault(search.isDefault ? null : search.id)} + savedSearches={savedSearches} + menuPortalRoot={menuPortalRoot} + /> + ); + })} +
+
+ )} +
+ ); +} + +// ============================================================================ +// Empty State +// ============================================================================ + +function EmptyState() { + return ( + + + No saved searches yet + + + ); +} + +// ============================================================================ +// Styles +// ============================================================================ + +function getStyles(theme: GrafanaTheme2) { + return { + dropdown: css({ + width: '320px', + padding: theme.spacing(0.5), + }), + list: css({ + maxHeight: '300px', + overflowY: 'auto', + }), + item: css({ + padding: theme.spacing(0.5), + borderRadius: theme.shape.radius.default, + '&:hover': { + backgroundColor: theme.colors.action.hover, + }, + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts b/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts new file mode 100644 index 00000000000..807ce421957 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/savedSearchesSchema.ts @@ -0,0 +1,87 @@ +/** + * Shared types and utilities for SavedSearches feature. + * + * This module is extracted to avoid circular dependencies between: + * - SavedSearches.tsx (main component) + * - InlineSaveInput.tsx, InlineRenameInput.tsx, SavedSearchItem.tsx (sub-components) + * - useSavedSearches.ts (hook) + */ + +import z from 'zod'; + +import { t } from '@grafana/i18n'; + +// ============================================================================ +// Schemas +// ============================================================================ + +/** + * Zod schema for validating a saved search object. + * Used to validate data loaded from storage. + */ +export const savedSearchSchema = z.object({ + id: z.string(), + name: z.string(), + isDefault: z.boolean(), + query: z.string(), + createdAt: z.number().optional(), +}); + +/** + * Zod schema for validating an array of saved searches. + */ +export const savedSearchesArraySchema = z.array(savedSearchSchema); + +// ============================================================================ +// Types +// ============================================================================ + +export type SavedSearch = z.infer; + +export interface ValidationError { + field: 'name'; + message: string; +} + +/** + * Type guard to check if an error is a ValidationError. + */ +export function isValidationError(error: unknown): error is ValidationError { + return Boolean( + error && + typeof error === 'object' && + 'field' in error && + 'message' in error && + error.field === 'name' && + typeof error.message === 'string' + ); +} + +// ============================================================================ +// Validation Utilities +// ============================================================================ + +/** + * Validates a saved search name. + * @param name - The name to validate + * @param savedSearches - Existing saved searches for uniqueness check + * @param excludeId - Optional ID to exclude from uniqueness check (for rename) + * @returns Error message string or null if valid + */ +export function validateSearchName(name: string, savedSearches: SavedSearch[], excludeId?: string): string | null { + const trimmed = name.trim(); + + if (!trimmed) { + return t('alerting.saved-searches.error-name-required', 'Name is required'); + } + + const isDuplicate = savedSearches.some( + (s) => (excludeId ? s.id !== excludeId : true) && s.name.toLowerCase() === trimmed.toLowerCase() + ); + + if (isDuplicate) { + return t('alerting.saved-searches.error-name-duplicate', 'A saved search with this name already exists'); + } + + return null; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts b/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts new file mode 100644 index 00000000000..380702fed16 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useApplyDefaultSearch.ts @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; + +import { shouldUseSavedSearches } from '../../featureToggles'; +import { useAsync } from '../../hooks/useAsync'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { getSearchFilterFromQuery } from '../../search/rulesSearchParser'; + +import { loadDefaultSavedSearch, trackSavedSearchAutoApply } from './useSavedSearches'; + +/** + * Session storage key to track if user has visited this page in current session. + * Used to determine if we should auto-apply the default saved search. + */ +const SESSION_VISITED_KEY = 'grafana.alerting.ruleList.visited'; + +/** + * Hook that automatically applies the default saved search on first visit to the page. + * + * This hook: + * - Checks if saved searches feature is enabled + * - Detects if this is the first visit in the current session + * - Loads and applies the default saved search if one exists + * - Cleans up session storage on unmount + * + * @returns Object with isApplying boolean indicating if the default search is being loaded/applied + */ +export function useApplyDefaultSearch(): { isApplying: boolean } { + const savedSearchesEnabled = shouldUseSavedSearches(); + const { updateFilters, hasActiveFilters } = useRulesFilter(); + + // Use the internal useAsync hook which doesn't auto-execute + const [{ execute }, state] = useAsync(async () => { + const defaultSearch = await loadDefaultSavedSearch(); + if (defaultSearch) { + updateFilters(getSearchFilterFromQuery(defaultSearch.query)); + trackSavedSearchAutoApply(); + } + }); + + // Clear session storage on unmount + useEffect(() => { + return () => { + clearSessionVisitedFlag(); + }; + }, []); + + const isFirstVisit = isFirstVisitInSession(); + const shouldLoadDefault = savedSearchesEnabled && !hasActiveFilters && isFirstVisit; + + // Mark as visited on first visit, regardless of whether we load defaults + if (isFirstVisit && state.status === 'not-executed') { + markAsVisited(); + + // Execute only if we should load default + if (shouldLoadDefault) { + execute(); + } + } + + return { isApplying: state.status === 'loading' }; +} + +/** + * Check if this is a fresh navigation to the page (not a refresh or in-page URL change). + * Uses session storage which persists across refreshes but clears when tab is closed. + * + * @returns true if this is the first visit to the page in this session + */ +function isFirstVisitInSession(): boolean { + return !sessionStorage.getItem(SESSION_VISITED_KEY); +} + +/** + * Mark the page as visited in the current session. + */ +function markAsVisited(): void { + sessionStorage.setItem(SESSION_VISITED_KEY, 'true'); +} + +/** + * Clear the session visited flag. Call this when component unmounts + * so the next navigation to this page is detected as a fresh visit. + */ +function clearSessionVisitedFlag(): void { + sessionStorage.removeItem(SESSION_VISITED_KEY); +} diff --git a/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx new file mode 100644 index 00000000000..4ea83f1e38a --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.test.tsx @@ -0,0 +1,400 @@ +import { PropsWithChildren } from 'react'; +import { act, getWrapper, renderHook, screen, waitFor } from 'test/test-utils'; + +import * as runtime from '@grafana/runtime'; +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; + +// Create mock UserStorage instance that can be configured per test +const mockUserStorage = { + getItem: jest.fn(), + setItem: jest.fn(), +}; + +// Mock UserStorage class from @grafana/runtime/internal +// This prevents the module-level instance from caching state across tests +jest.mock('@grafana/runtime/internal', () => ({ + ...jest.requireActual('@grafana/runtime/internal'), + UserStorage: jest.fn().mockImplementation(() => mockUserStorage), +})); + +// Mock config BEFORE any imports that use it (jest.mock is hoisted) +// This ensures config.namespace and config.bootData.user are set when UserStorage module loads +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + config: { + ...jest.requireActual('@grafana/runtime').config, + namespace: 'default', + bootData: { + ...jest.requireActual('@grafana/runtime').config.bootData, + navTree: [], + user: { + uid: 'test-user-123', + id: 123, + isSignedIn: true, + }, + }, + }, +})); + +import { trackSavedSearchApplied, useSavedSearches } from './useSavedSearches'; + +// Set up MSW server for other handlers (not UserStorage - that's mocked directly) +setupMswServer(); + +// Mock data is ordered as it will appear after sorting by useSavedSearches: +// default search first, then alphabetically by name +const mockSavedSearches = [ + { + id: '2', + name: 'Default Search', + query: 'label:team=A', + isDefault: true, + createdAt: Date.now() - 2000, + }, + { + id: '1', + name: 'Test Search 1', + query: 'state:firing', + isDefault: false, + createdAt: Date.now() - 1000, + }, +]; + +// Wrapper that includes AppNotificationList to verify UI notifications +function createWrapper() { + const Wrapper = getWrapper({ renderWithRouter: true }); + return function WrapperWithNotifications({ children }: PropsWithChildren) { + return ( + + + {children} + + ); + }; +} + +describe('useSavedSearches', () => { + beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + // Reset mock UserStorage to default behavior (empty storage) + mockUserStorage.getItem.mockResolvedValue(null); + mockUserStorage.setItem.mockResolvedValue(undefined); + }); + + describe('Initial loading', () => { + it('should load saved searches from UserStorage', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual(mockSavedSearches); + }); + + it('should handle empty storage gracefully', async () => { + // Storage is empty by default after resetUserStorage() in afterEach + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toEqual([]); + }); + + it('should handle 404 (no stored data) gracefully', async () => { + // When no data exists, UserStorage returns 404, which is handled as "not found" + // The hook should return an empty array without error + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // UserStorage handles 404 gracefully - returns empty storage + expect(result.current.savedSearches).toEqual([]); + }); + + it('should filter out invalid saved search entries', async () => { + jest.spyOn(console, 'warn').mockImplementation(); + const mixedData = [ + mockSavedSearches[0], // Valid + { id: '3', name: 'Invalid', query: 123, isDefault: false }, // Invalid query type + { id: '4', name: null, query: 'valid', isDefault: false }, // Invalid name type + mockSavedSearches[1], // Valid + ]; + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mixedData)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Invalid entries are filtered out, valid ones are preserved + expect(result.current.savedSearches).toHaveLength(2); + expect(result.current.savedSearches[0].name).toBe('Default Search'); + expect(result.current.savedSearches[1].name).toBe('Test Search 1'); + }); + + it('should handle malformed JSON gracefully', async () => { + jest.spyOn(console, 'error').mockImplementation(); + mockUserStorage.getItem.mockResolvedValue('not valid json'); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Verify error notification appears in the UI + expect(await screen.findByText(/failed to load saved searches/i)).toBeInTheDocument(); + expect(result.current.savedSearches).toEqual([]); + }); + }); + + describe('saveSearch', () => { + it('should save a new search', async () => { + // Start with empty storage (default mock behavior) + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveSearch('New Search', 'state:pending'); + }); + + expect(result.current.savedSearches).toHaveLength(1); + expect(result.current.savedSearches[0].name).toBe('New Search'); + expect(result.current.savedSearches[0].query).toBe('state:pending'); + + // Verify data was persisted via UserStorage.setItem + expect(mockUserStorage.setItem).toHaveBeenCalledWith( + 'savedSearches', + expect.stringContaining('"name":"New Search"') + ); + }); + + it('should throw validation error for duplicate name (case-insensitive)', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await expect(result.current.saveSearch('TEST SEARCH 1', 'state:pending')).rejects.toEqual({ + field: 'name', + message: expect.stringContaining('already exists'), + }); + }); + }); + + it('should track analytics on save', async () => { + // Start with empty storage + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.saveSearch('New Search', 'state:pending'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith( + 'grafana_alerting_saved_search_save', + expect.objectContaining({ + hasDefault: false, + totalCount: 1, + }) + ); + }); + }); + + describe('renameSearch', () => { + it('should rename an existing search', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.renameSearch('1', 'Renamed Search'); + }); + + expect(result.current.savedSearches.find((s) => s.id === '1')?.name).toBe('Renamed Search'); + + // Verify data was persisted via UserStorage.setItem + expect(mockUserStorage.setItem).toHaveBeenCalledWith( + 'savedSearches', + expect.stringContaining('"name":"Renamed Search"') + ); + }); + + it('should throw validation error for duplicate name on rename', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await expect(result.current.renameSearch('1', 'Default Search')).rejects.toEqual({ + field: 'name', + message: expect.stringContaining('already exists'), + }); + }); + }); + }); + + describe('deleteSearch', () => { + it('should delete a search', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.savedSearches).toHaveLength(2); + + await act(async () => { + await result.current.deleteSearch('1'); + }); + + expect(result.current.savedSearches).toHaveLength(1); + expect(result.current.savedSearches.find((s) => s.id === '1')).toBeUndefined(); + }); + + it('should track analytics on delete', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.deleteSearch('1'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_delete'); + }); + }); + + describe('setDefaultSearch', () => { + it('should set a search as default', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch('1'); + }); + + expect(result.current.savedSearches.find((s) => s.id === '1')?.isDefault).toBe(true); + expect(result.current.savedSearches.find((s) => s.id === '2')?.isDefault).toBe(false); + }); + + it('should clear default when null is passed', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch(null); + }); + + expect(result.current.savedSearches.every((s) => !s.isDefault)).toBe(true); + }); + + it('should track analytics with correct action', async () => { + mockUserStorage.getItem.mockResolvedValue(JSON.stringify(mockSavedSearches)); + + const { result } = renderHook(() => useSavedSearches(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.setDefaultSearch('1'); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_set_default', { + action: 'set', + }); + + jest.clearAllMocks(); + + await act(async () => { + await result.current.setDefaultSearch(null); + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_set_default', { + action: 'clear', + }); + }); + }); +}); + +describe('trackSavedSearchApplied', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should track with isDefault true for default searches', () => { + trackSavedSearchApplied({ + id: '1', + name: 'Default', + query: 'state:firing', + isDefault: true, + createdAt: Date.now(), + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_apply', { isDefault: true }); + }); + + it('should track with isDefault false for non-default searches', () => { + trackSavedSearchApplied({ + id: '1', + name: 'Regular', + query: 'state:firing', + isDefault: false, + createdAt: Date.now(), + }); + + expect(runtime.reportInteraction).toHaveBeenCalledWith('grafana_alerting_saved_search_apply', { isDefault: false }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts new file mode 100644 index 00000000000..5c81ce990b3 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/useSavedSearches.ts @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { t } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { UserStorage } from '@grafana/runtime/internal'; + +import { useAppNotification } from '../../../../../core/copy/appNotification'; +import { logError, logWarning } from '../../Analytics'; +import { isLoading as isLoadingState, isUninitialized, useAsync } from '../../hooks/useAsync'; + +import { SavedSearch, savedSearchSchema, savedSearchesArraySchema, validateSearchName } from './savedSearchesSchema'; + +/** + * Storage key for saved searches in UserStorage. + */ +const SAVED_SEARCHES_STORAGE_KEY = 'savedSearches'; + +/** + * UserStorage instance for saved searches. + * Uses 'alerting' as the service namespace. + */ +const userStorage = new UserStorage('alerting'); + +/** + * Analytics tracking functions for saved search actions. + */ +function trackSavedSearchSave(props: { hasDefault: boolean; totalCount: number }) { + reportInteraction('grafana_alerting_saved_search_save', props); +} + +function trackSavedSearchApply(props: { isDefault: boolean }) { + reportInteraction('grafana_alerting_saved_search_apply', props); +} + +function trackSavedSearchDelete() { + reportInteraction('grafana_alerting_saved_search_delete'); +} + +function trackSavedSearchRename() { + reportInteraction('grafana_alerting_saved_search_rename'); +} + +function trackSavedSearchSetDefault(props: { action: 'set' | 'clear' }) { + reportInteraction('grafana_alerting_saved_search_set_default', props); +} + +export function trackSavedSearchAutoApply() { + reportInteraction('grafana_alerting_saved_search_auto_apply'); +} + +/** + * Validates and parses an array of saved searches using zod schema. + * Returns valid entries and logs warnings for invalid data. + */ +function validateSavedSearches(data: unknown): SavedSearch[] { + const result = savedSearchesArraySchema.safeParse(data); + + if (result.success) { + return result.data; + } + + // If the whole array failed, try to salvage individual valid entries + if (!Array.isArray(data)) { + logWarning('Saved searches data is not an array, returning empty array'); + return []; + } + + logWarning('Saved searches validation failed, filtering invalid entries', { + issues: JSON.stringify(result.error.issues), + }); + + const validEntries: SavedSearch[] = []; + for (const item of data) { + const itemResult = savedSearchSchema.safeParse(item); + if (itemResult.success) { + validEntries.push(itemResult.data); + } + } + return validEntries; +} + +/** + * Sorts saved searches: default search first, then others alphabetically. + * @param searches - Array of saved searches to sort + * @returns Sorted array with default first, then alphabetically by name + */ +function sortSavedSearches(searches: SavedSearch[]): SavedSearch[] { + const defaultSearch = searches.find((s) => s.isDefault); + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + const others = searches.filter((s) => !s.isDefault).sort((a, b) => collator.compare(a.name, b.name)); + + return defaultSearch ? [defaultSearch, ...others] : others; +} + +/** + * Loads saved searches from UserStorage and validates the data. + * @returns Promise resolving to an array of valid saved searches + */ +async function loadSavedSearchesFromStorage(): Promise { + const stored = await userStorage.getItem(SAVED_SEARCHES_STORAGE_KEY); + if (!stored) { + return []; + } + + const parsed = JSON.parse(stored); + return validateSavedSearches(parsed); +} + +export async function loadDefaultSavedSearch(): Promise { + const savedSearches = await loadSavedSearchesFromStorage(); + return savedSearches.find((s) => s.isDefault) ?? null; +} + +/** + * Result of the useSavedSearches hook. + */ +export interface UseSavedSearchesResult { + /** List of saved searches */ + savedSearches: SavedSearch[]; + /** Whether the initial load from storage is complete */ + isLoading: boolean; + /** + * Save a new search with the given name and query. + * @param name - The display name for the saved search + * @param query - The search query string + * @throws ValidationError if name is not unique + */ + saveSearch: (name: string, query: string) => Promise; + /** + * Rename an existing saved search. + * @param id - The ID of the search to rename + * @param newName - The new display name + * @throws ValidationError if name is not unique + */ + renameSearch: (id: string, newName: string) => Promise; + /** + * Delete a saved search by ID. + * @param id - The ID of the search to delete + */ + deleteSearch: (id: string) => Promise; + /** + * Set or clear the default search. + * @param id - The ID to set as default, or null to clear + */ + setDefaultSearch: (id: string | null) => Promise; +} + +/** + * Hook for managing saved searches with UserStorage persistence. + * + * Features: + * - Persists saved searches to UserStorage (syncs across devices) + * - Validates data schema on load (filters invalid entries) + * - Validates name uniqueness (case-insensitive) + * - Tracks analytics for all actions + * - Shows error notifications on storage failures + * - Provides auto-apply logic for default search on navigation + * - Per-user session tracking to handle logout/login scenarios + * + * @example + * ```tsx + * const { savedSearches, saveSearch, isLoading } = useSavedSearches(); + * + * // Save current search + * const error = await saveSearch('My Search', currentQuery); + * if (error) { + * // Handle validation error + * } + * + * // Auto-apply default search on mount + * useEffect(() => { + * const defaultSearch = getAutoApplySearch(); + * if (defaultSearch) { + * applySearch(defaultSearch); + * } + * }, []); + * ``` + */ +export function useSavedSearches(): UseSavedSearchesResult { + const [savedSearches, setSavedSearches] = useState([]); + const notifyApp = useAppNotification(); + + // Track whether we've already loaded to prevent double-loading + const hasLoadedRef = useRef(false); + + // Use useAsync for loading state management + const [{ execute: executeLoad }, loadState] = useAsync(loadSavedSearchesFromStorage, []); + const isLoading = isLoadingState(loadState) || isUninitialized(loadState); + + // Load saved searches from storage on mount + useEffect(() => { + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + + // Load from UserStorage using async/await pattern + const loadSearches = async () => { + try { + const validated = await executeLoad(); + setSavedSearches(validated); + } catch (error) { + logError(error instanceof Error ? error : new Error('Failed to load saved searches from storage'), { + context: 'useSavedSearches.loadSearches', + }); + notifyApp.error( + t('alerting.saved-searches.error-load-title', 'Failed to load saved searches'), + t( + 'alerting.saved-searches.error-load-description', + 'Your saved searches could not be loaded. Please try refreshing the page.' + ) + ); + } + }; + + loadSearches(); + }, [executeLoad, notifyApp]); + + /** + * Persist saved searches to UserStorage. + */ + const persistSearches = useCallback( + async (searches: SavedSearch[]): Promise => { + try { + await userStorage.setItem(SAVED_SEARCHES_STORAGE_KEY, JSON.stringify(searches)); + } catch (error) { + logError(error instanceof Error ? error : new Error('Failed to save searches'), { + context: 'useSavedSearches.persistSearches', + }); + notifyApp.error( + t('alerting.saved-searches.error-save-title', 'Failed to save'), + t('alerting.saved-searches.error-save-description', 'Your changes could not be saved. Please try again.') + ); + throw error; + } + }, + [notifyApp] + ); + + /** + * Save a new search with the given name and query. + * @throws ValidationError if the name is not unique + */ + const saveSearch = useCallback( + async (name: string, query: string): Promise => { + // Validate name using shared validation function + const validationError = validateSearchName(name, savedSearches); + if (validationError) { + throw { field: 'name' as const, message: validationError }; + } + + const newSearch: SavedSearch = { + id: uuidv4(), + name, + query, + isDefault: false, + createdAt: Date.now(), + }; + + const newSearches = [...savedSearches, newSearch]; + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchSave({ + hasDefault: newSearches.some((s) => s.isDefault), + totalCount: newSearches.length, + }); + }, + [savedSearches, persistSearches] + ); + + /** + * Rename an existing saved search. + * @throws ValidationError if the new name is not unique + */ + const renameSearch = useCallback( + async (id: string, newName: string): Promise => { + // Validate name using shared validation function (excluding current item) + const validationError = validateSearchName(newName, savedSearches, id); + if (validationError) { + throw { field: 'name' as const, message: validationError }; + } + + const newSearches = savedSearches.map((s) => (s.id === id ? { ...s, name: newName } : s)); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchRename(); + }, + [savedSearches, persistSearches] + ); + + /** + * Delete a saved search by ID. + */ + const deleteSearch = useCallback( + async (id: string): Promise => { + const newSearches = savedSearches.filter((s) => s.id !== id); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchDelete(); + }, + [savedSearches, persistSearches] + ); + + /** + * Set or clear the default search. + * Pass null to clear the current default. + */ + const setDefaultSearch = useCallback( + async (id: string | null): Promise => { + const newSearches = savedSearches.map((s) => ({ + ...s, + isDefault: id === null ? false : s.id === id, + })); + + await persistSearches(newSearches); + setSavedSearches(newSearches); + + // Track analytics + trackSavedSearchSetDefault({ action: id === null ? 'clear' : 'set' }); + }, + [savedSearches, persistSearches] + ); + + // Sort saved searches: default first, then alphabetically by name + const sortedSavedSearches = useMemo(() => sortSavedSearches(savedSearches), [savedSearches]); + + return { + savedSearches: sortedSavedSearches, + isLoading, + saveSearch, + renameSearch, + deleteSearch, + setDefaultSearch, + }; +} + +/** + * Track when a saved search is applied (called from parent component). + * @param search - The search that was applied + */ +export function trackSavedSearchApplied(search: SavedSearch) { + trackSavedSearchApply({ isDefault: search.isDefault }); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 53abfa01d14..7a1dd06e31f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federated", "text-provisioned": "Provisioned" }, + "saved-searches": { + "actions-aria-label": "Actions", + "apply-aria-label": "Apply search \"{{name}}\"", + "apply-tooltip": "Apply this search", + "button-label": "Saved searches", + "cancel": "Cancel", + "default-indicator": "Default search", + "delete": "Delete", + "delete-button": "Delete", + "dropdown-aria-label": "Saved searches", + "empty-state": "No saved searches yet", + "error-load-description": "Your saved searches could not be loaded. Please try refreshing the page.", + "error-load-title": "Failed to load saved searches", + "error-name-duplicate": "A saved search with this name already exists", + "error-name-required": "Name is required", + "error-rename-description": "Your changes could not be saved. Please try again.", + "error-rename-title": "Failed to rename", + "error-save-description": "Your changes could not be saved. Please try again.", + "error-save-title": "Failed to save", + "list-aria-label": "Saved searches list", + "name-placeholder": "Enter a name...", + "remove-default": "Remove default", + "rename": "Rename", + "rename-button": "Rename", + "save-button": "Save", + "save-current-search": "Save current search", + "save-disabled-tooltip": "Enter a search query first", + "set-default": "Set as default" + }, "search": { "property": { "data-source": "Data source",