Alerting: Add saved searches feature for alert rules page (#115001)

* Alerting: Add saved searches feature for alert rules page

Add ability to save, rename, delete, and apply search queries on the
Alert Rules page. Includes auto-apply default search on navigation
and UserStorage persistence.

Behind feature toggle `alertingSavedSearches` (disabled by default).

* Alerting: Add i18n translations for saved searches

* Alerting: Remove unused imports in saved searches

* Alerting: Add CODEOWNERS for e2e-playwright/alerting-suite

* Alerting: Add useSavedSearches mock to RulesFilter.v2 tests

* Alerting: Fix failing unit tests for saved searches

- Fix Jest mock hoisting issue in useSavedSearches.test.ts by configuring
  UserStorage mock implementation after imports instead of inline
- Update SavedSearches.test.tsx to use findBy* queries for async popup content
- Fix tests to click apply button instead of text for applying searches
- Update maxLength test to verify attribute instead of trying to exceed it

* Alerting: Fix saved searches test mocking and assertions

- Fix UserStorage mock in useSavedSearches.test.ts by creating mock with
  default Promise-returning functions inside jest.mock() factory, then
  accessing the instance via getMockUserStorageInstance() helper
- Fix SavedSearches.test.tsx apply button tests to use correct accessible
  name "Apply this search" (from tooltip) instead of dynamic aria-label
- Fix disabled button assertion to check native disabled attribute instead
  of relying on aria-disabled which is set inconsistently by Button component
- Use findAllByRole for async popup content queries

* Alerting: Fix test query for disabled save button

Use findByText + closest instead of findByRole to find the disabled
"Save current search" button. The Grafana Button component renders
with conflicting accessibility attributes (disabled="" + aria-disabled="false")
which breaks role-based queries in React Testing Library.

* fix(alerting): preserve UserStorage mock reference before clearAllMocks

* fix(alerting): add missing test mocks for crypto and console

- Mock crypto.randomUUID for Node.js test environment
- Add console.error spy to tests expecting storage/parse errors
- Add console.warn spy to test expecting validation warnings

Fixes jest-fail-on-console failures and crypto.randomUUID TypeError.

* fix(alerting): add console.error spy to save failure test

* fix(alerting): address PR review feedback for saved searches

- Register alertingSavedSearches feature toggle in backend
- Extract shared types to SavedSearches.types.ts to fix circular dependencies
- Extract sub-components: InlineSaveInput, InlineRenameInput, SavedSearchItem
- Remove unused imports (IconButton, Input) and styles from SavedSearches.tsx
- Add try/catch for auto-apply default search error handling
- Remove maxLength validation and corresponding test

* fix(alerting): fix validation error display in saved searches

- Fix useEffect dependency array that was immediately clearing validation errors
- Remove error from deps so errors only clear when user types, not when set
- Run i18n-extract to remove unused error-name-too-long translation key

* fix(alerting): address PR review feedback for saved searches

- Replace toHaveBeenCalled assertions with UI verification using AppNotificationList
- Rename useSavedSearches.test.ts to .tsx for JSX support
- Update README documentation to reflect current test patterns
- Add test cleanup between E2E tests to prevent data leakage

* fix(alerting): remove unused import and fix test wrapper

- Remove unused locationService import from RulesFilter.v2.tsx
- Add missing bootData spread in useSavedSearches.test.tsx mock
- Add createWrapper to renderHook call for user-specific storage key test

* fix(alerting): add Redux wrapper to all useSavedSearches hook tests

All renderHook calls for useSavedSearches now include the createWrapper()
which provides the Redux Provider context required by useAppNotification.

* fix(alerting): use regex patterns in MSW handlers for UserStorage tests

MSW handlers now use regex patterns to match any namespace and user UID,
since UserStorage reads config values from internal imports that aren't
affected by jest.mock of @grafana/runtime.

* fix(alerting): mock UserStorage directly instead of using MSW

Replace MSW HTTP handlers with a direct mock of the UserStorage class.
The MSW approach failed because UserStorage evaluates config.namespace
at module load time, before jest.mock takes effect, causing the regex
patterns to not match the actual request URLs.

This follows the same pattern used in useFavoriteDatasources.test.ts.

* refactor(alerting): use react-hook-form and Dropdown for saved searches

- Migrate InlineRenameInput and InlineSaveInput to react-hook-form
- Replace custom PopupCard with Grafana Dropdown component
- Use useReducer for centralized dropdown state management
- Add stopPropagation handlers to prevent dropdown closing during form interactions
- Update tests to use real useSavedSearches hook with mocked UserStorage
- Consolidate and simplify saved searches test suite

* fix: resolve CI failures in SavedSearches component

- Fix TypeScript TS2540 errors by using MutableRefObject type for refs
- Fix form submission by using onClick instead of type="submit" on IconButton
  (IconButton doesn't forward the type prop to the underlying button)
- Fix action menu tests by stopping click propagation on ActionMenu wrapper
- Fix Escape key handling by focusing the dialog element instead of the
  potentially-disabled save button

* fix(alerting): add navTree to runtime mock in useSavedSearches tests

Add empty navTree array to the @grafana/runtime config mock to prevent
store initialization crash when buildInitialState() calls .find() on
undefined navTree.

* fix(alerting): add error handling for auto-apply default search

Wrap handleApplySearch call in try-catch to prevent unhandled exceptions
when auto-applying the default saved search on navigation.

* fix(alerting): prevent saved searches dropdown from closing when clicking action menu

The nested Dropdown components caused the outer SavedSearches dropdown to close
when clicking on action menu items (Set as default, Rename, Delete). This happened
because @floating-ui/react's useDismiss hook detected clicks on the inner Menu
(rendered via Portal) as "outside" clicks.

Fix: Replace the outer Dropdown with PopupCard and add custom click-outside
handling that explicitly excludes portal elements ([role="menu"] and
[data-popper-placement]). This matches the pattern used before the Dropdown
refactor.

Changes:
- SavedSearches.tsx: Use PopupCard instead of Dropdown, add click-outside handler
- SavedSearchItem.tsx: Add menuPortalRoot prop for action menu positioning
- RulesFilter.v2.tsx: Fix double analytics tracking on auto-apply

* fix(alerting): auto-apply default saved search on page navigation

The default saved search was not being applied when navigating to the
Alert rules page. This was caused by a race condition where `isLoading`
was `false` on initial render (status was 'not-executed'), causing the
auto-apply effect to run before saved searches were loaded.

Fix: Include the uninitialized state in the loading check so the effect
waits until data is actually loaded before attempting to auto-apply.

Also adds tests for the auto-apply functionality.

* fix(alerting): align action menu icon and improve saved search tests

- Fix vertical alignment of three-dot menu icon in saved search items
  by adding flex centering to the wrapper div
- Add feature toggle setup/teardown in saved searches test suite
- Fix location mocking in test for URL search parameter handling

* refactor(alerting): improve saved searches validation and organization

- Rename SavedSearches.types.ts to savedSearchesSchema.ts
- Use react-hook-form's built-in validation instead of manual setError
- Change error handling to throw ValidationError instead of returning it
- Add type guard isValidationError for safe error checking
- Add alphabetical sorting for saved searches (default first)
- Replace console.warn/error with logWarning/logError for analytics
- Extract helper functions: sortSavedSearches, loadSavedSearchesFromStorage, hasUrlSearchQuery

* refactor(alerting): address PR review comments for saved searches (steps 9-12)

- Add comprehensive comment explaining useEffect double-render limitation
  and potential future improvements for default search auto-apply (step 9)
- Add test documenting expected behavior when navigating back to alert list
  after leaving the page - default filter is re-applied (step 10)
- Update RulesFilter.v2.test.tsx to use testWithFeatureToggles helper and
  add MSW UserStorage handlers for future use (step 11)
- Update SavedSearches.test.tsx to use render from test/test-utils and
  byRole selectors for menu items (step 12)

* test(alerting): update saved searches tests for refactored API

- Update mockSavedSearches order to match sorted output (default first, then alphabetically)
- Change validation error tests to use rejects pattern (saveSearch/renameSearch now throw)
- Add hasPermission mock to contextSrv for module-level permission check

* fix(alerting): fix CI failures for saved searches

- Update onRenameComplete type to match throw-based API (Promise<void>)
- Run i18n-extract to add missing translation keys

* fix(alerting): salvage valid entries when saved searches validation fails

Instead of returning an empty array when array validation fails,
iterate through each item and keep only the valid entries.
This prevents losing all saved searches if a single entry is corrupted.

* test(alerting): update test to expect valid entries to be preserved

Update the test assertion to match the new behavior where valid saved
search entries are preserved when some entries fail validation, rather
than discarding all entries.

* fix(alerting): eliminate double API request on saved search auto-apply

Move saved searches loading and auto-apply logic from RulesFilterV2 to
RuleListPage. This ensures the default search filter is applied BEFORE
FilterView mounts, preventing double API requests on initial page load.

- Load saved searches at RuleListPage level
- Gate RuleList rendering until saved searches are loaded
- Pass savedSearchesResult as prop to avoid duplicate hook calls
- Remove auto-apply tests from RulesFilter.v2.test.tsx (behavior moved)

* fix(alerting): mock useSavedSearches in RuleList.v2 tests

The useSavedSearches hook triggers async state updates that complete
after tests finish, causing React act() warnings. Mock the hook to
prevent async operations during tests.

* refactor(alerting): migrate saved searches tests to use MSW

Address code review feedback by migrating UserStorage tests from
jest.mock to MSW-based mocking:

- Add MSW helper functions (setAlertingStorageItem, getAlertingStorageItem)
  to simplify test setup for UserStorage
- Migrate useSavedSearches.test.tsx to use MSW handlers instead of
  jest.mock('@grafana/runtime/internal')
- Migrate RulesFilter.v2.test.tsx to use MSW handlers
- Update README documentation to accurately reflect how tests use MSW
- Add tests for default search auto-apply behavior in RuleListPage
- Simplify comments to be concise and accurate

* fix(alerting): mock UserStorage directly in useSavedSearches tests

The UserStorage class caches its storage spec at the instance level,
and the useSavedSearches hook creates the instance at module level.
This caused test isolation issues where cached state leaked between
tests, making all tests that depended on loading data fail.

Fix by mocking UserStorage class directly instead of relying on MSW
handlers. This gives each test explicit control over what getItem
and setItem return, ensuring proper isolation.

Also update persistence assertions to verify mock.setItem calls
instead of reading from MSW storage (which the mock bypasses).

* refactor(alerting): remove setup helper in SavedSearches tests

Replace the `setup()` helper function with direct `render()` calls
as suggested in PR review. This makes tests more explicit about
what component is being rendered and with what props.

* refactor(alerting): extract default search auto-apply into dedicated hook

Moves the default saved search auto-apply logic from useSavedSearches into
a new useApplyDefaultSearch hook. This improves separation of concerns by
keeping useSavedSearches focused on CRUD operations while the new hook
handles the page-level auto-apply behavior.

Key changes:
- Created useApplyDefaultSearch hook with session-based visit tracking
- Removed getAutoApplySearch method and user-specific session keys from useSavedSearches
- Exported loadDefaultSavedSearch utility for independent default search loading
- Simplified test mocks to use loadDefaultSavedSearch instead of full hook mocking
- Removed unused savedSearchesResult prop passing through component tree

* fix(alerting): improve default search auto-apply timing and test reliability

Replace react-use's auto-executing useAsync with internal useAsync hook
for better control over when default search is loaded. This prevents
race conditions and ensures the async operation only executes when needed.

Test improvements:
- Add proper session storage cleanup in beforeEach
- Use waitFor to handle async operations correctly
- Prevent visited flag from affecting subsequent tests
- Clear mock call history between tests

The internal useAsync hook doesn't auto-execute on mount, allowing us to
control exactly when the default search loads based on conditions rather
than relying on dependency array triggers.

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Deyan Halachliyski 2025-12-19 15:32:27 +01:00 committed by GitHub
parent 133865182e
commit 62b2a202de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 3508 additions and 60 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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();
});
}
);

View file

@ -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;

View file

@ -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.",

View file

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
122 dashboardTemplates preview @grafana/sharing-squad false false false
123 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
124 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
125 alertingSavedSearches experimental @grafana/alerting-squad false false true
126 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
127 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
128 alertingCentralAlertHistory experimental @grafana/alerting-squad false false false

View file

@ -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",

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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<string, UserStorageSpec> = {};
/**
* 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;

View file

@ -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: () => <div data-testid="grouped-view">Grouped View</div>,
}));
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<typeof loadDefaultSavedSearch>;
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(<RuleList />);
render(<RuleListPage />);
expect(ui.groupedView.get()).toBeInTheDocument();
expect(ui.filterView.query()).not.toBeInTheDocument();
});
it('should show grouped view when invalid view parameter is provided', () => {
render(<RuleList />, {
render(<RuleListPage />, {
historyOptions: {
initialEntries: ['/?view=invalid'],
},
@ -65,35 +90,35 @@ describe('RuleList v2', () => {
});
it('should show list view when "view=list" URL parameter is present', () => {
render(<RuleList />, { historyOptions: { initialEntries: ['/?view=list'] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?search=namespace:global'] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } });
render(<RuleListPage />, { 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(<RuleList />, {
render(<RuleListPage />, {
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(<RuleList />, { historyOptions: { initialEntries: ['/?view='] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?search='] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } });
render(<RuleListPage />, { 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(<RuleList />, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } });
render(<RuleListPage />, { 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(<RuleList />, {
render(<RuleListPage />, {
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(<RuleList />, {
const { user } = render(<RuleListPage />, {
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(<RuleList />, {
const { user } = render(<RuleListPage />, {
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(<RuleListPage />);
// 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(<RuleListPage />, {
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(<RuleListPage />);
// Wait for the component to render after checking for default search
await waitFor(() => {
expect(ui.groupedView.get()).toBeInTheDocument();
});
expect(ui.searchInput.get()).toHaveValue('');
});
});

View file

@ -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 (
<AlertingPageWrapper
navId="alert-list"
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={false}
isLoading={isApplying}
actions={<RuleListActions />}
>
<RuleList />
{!isApplying && <RuleList />}
</AlertingPageWrapper>
);
}

View file

@ -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<void>;
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<FormValues>({
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 (
<Stack direction="column" gap={0.5}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack direction="row" alignItems="center" gap={1} wrap={false}>
{/* Input area - flex=1 like the name area in list items */}
<Box flex={1} marginRight={2}>
<Input
{...register('name', {
required: t('alerting.saved-searches.error-name-required', 'Name is required'),
validate: (value) => {
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}
/>
</Box>
{/* X icon - cancel */}
<IconButton
name="times"
aria-label={t('alerting.saved-searches.cancel', 'Cancel')}
onClick={onCancel}
disabled={isSubmitting}
tooltip={t('alerting.saved-searches.cancel', 'Cancel')}
size="md"
variant="secondary"
type="button"
/>
{/* Check icon - confirm rename */}
{/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */}
<IconButton
name="check"
aria-label={t('alerting.saved-searches.rename-button', 'Rename')}
disabled={isSubmitting}
size="md"
tooltip={t('alerting.saved-searches.rename-button', 'Rename')}
className={styles.successIcon}
variant="secondary"
onClick={handleSubmit(onSubmit)}
/>
</Stack>
</form>
{errors.name?.message && (
<Text color="error" variant="bodySmall">
{errors.name.message}
</Text>
)}
</Stack>
);
}
// ============================================================================
// Styles
// ============================================================================
function getStyles(theme: GrafanaTheme2) {
return {
successIcon: css({
color: theme.colors.success.main,
}),
};
}

View file

@ -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<void>;
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<FormValues>({
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 (
<Stack direction="column" gap={0.5}>
{/* Match exact structure of SavedSearchItem: [flex-1 content] [icon] [icon] with gap={1} */}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack direction="row" alignItems="center" gap={1} wrap={false}>
{/* Input area - flex=1 like the name area in list items */}
<Box flex={1} marginRight={2}>
<Input
{...register('name', {
required: t('alerting.saved-searches.error-name-required', 'Name is required'),
validate: (value) => {
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}
/>
</Box>
{/* X icon - aligned with magnifying glass */}
<IconButton
name="times"
aria-label={t('alerting.saved-searches.cancel', 'Cancel')}
onClick={onCancel}
disabled={isSubmitting}
tooltip={t('alerting.saved-searches.cancel', 'Cancel')}
size="md"
variant="secondary"
type="button"
/>
{/* Check icon - aligned with action menu */}
{/* Note: IconButton doesn't forward type="submit", so we use onClick with handleSubmit */}
<IconButton
name="check"
aria-label={t('alerting.saved-searches.save-button', 'Save')}
disabled={isSubmitting}
tooltip={t('alerting.saved-searches.save-button', 'Save')}
className={styles.successIcon}
size="md"
variant="secondary"
onClick={handleSubmit(onSubmit)}
/>
</Stack>
</form>
{errors.name?.message && (
<Text color="error" variant="bodySmall">
{errors.name.message}
</Text>
)}
</Stack>
);
}
// ============================================================================
// Styles
// ============================================================================
function getStyles(theme: GrafanaTheme2) {
return {
successIcon: css({
color: theme.colors.success.main,
}),
};
}

View file

@ -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<typeof useRulesFilter>;
// 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(<RulesFilter />);
// 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(<RulesFilter />);
it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => {
render(<RulesFilter />);
// 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(<RulesFilter />);
// 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(<RulesFilterV2 />);
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)
});

View file

@ -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<HTMLDivElement>(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<SearchQueryForm>({
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<SearchQueryForm> = (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}
</Button>
</PopupCard>
<RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} />
{savedSearchesEnabled && (
<SavedSearches
savedSearches={savedSearches}
currentSearchQuery={searchQuery}
onSave={saveSearch}
onRename={renameSearch}
onDelete={deleteSearch}
onApply={handleApplySearch}
onSetDefault={setDefaultSearch}
isLoading={savedSearchesLoading}
/>
)}
<Box marginLeft={2}>
<RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} />
</Box>
</Stack>
</Stack>
</form>

View file

@ -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<void>;
onStartDelete: () => void;
onCancelDelete: () => void;
onDeleteConfirm: () => Promise<void>;
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
<div className={styles.item} role="listitem" onClick={(e) => e.stopPropagation()}>
<InlineRenameInput
initialValue={search.name}
onSave={onRenameComplete}
onCancel={onCancelRename}
savedSearches={savedSearches}
excludeId={search.id}
/>
</div>
);
}
// 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
<div className={styles.item} role="listitem" onClick={(e) => e.stopPropagation()}>
<Stack direction="row" alignItems="center" gap={1} wrap={false}>
{/* Name remains visible */}
<Stack direction="row" alignItems="center" gap={0.5} flex={1}>
<Text truncate>{search.name}</Text>
</Stack>
{/* X icon - cancel delete */}
<IconButton
name="times"
aria-label={t('alerting.saved-searches.cancel', 'Cancel')}
onClick={onCancelDelete}
tooltip={t('alerting.saved-searches.cancel', 'Cancel')}
size="md"
variant="secondary"
/>
{/* Trash icon - confirm delete */}
<IconButton
name="trash-alt"
aria-label={t('alerting.saved-searches.delete-button', 'Delete')}
onClick={onDeleteConfirm}
tooltip={t('alerting.saved-searches.delete-button', 'Delete')}
size="md"
variant="secondary"
className={styles.deleteIcon}
/>
</Stack>
</div>
);
}
// Default display mode
return (
<div className={styles.item} role="listitem">
<Stack direction="row" alignItems="center" gap={1} wrap={false}>
{/* Name and default indicator */}
<Stack direction="row" alignItems="center" gap={0.5} flex={1}>
<Text truncate>{search.name}</Text>
{search.isDefault && (
<Icon
name="favorite"
size="sm"
className={styles.defaultIcon}
title={t('alerting.saved-searches.default-indicator', 'Default search')}
/>
)}
</Stack>
{/* Apply button (magnifying glass) */}
<IconButton
name="search"
aria-label={t('alerting.saved-searches.apply-aria-label', 'Apply search "{{name}}"', {
name: search.name,
})}
onClick={onApply}
tooltip={t('alerting.saved-searches.apply-tooltip', 'Apply this search')}
size="md"
variant="secondary"
disabled={isDisabled}
/>
{/* 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 */}
<div className={styles.actionMenuWrapper} onClick={(e) => e.stopPropagation()}>
<ActionMenu
isDefault={search.isDefault}
isDisabled={isDisabled}
onSetDefault={onSetDefault}
onRename={onStartRename}
onDelete={onStartDelete}
portalRoot={menuPortalRoot}
/>
</div>
</Stack>
</div>
);
}
// ============================================================================
// 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 = (
<Menu>
<Menu.Item
label={
isDefault
? t('alerting.saved-searches.remove-default', 'Remove default')
: t('alerting.saved-searches.set-default', 'Set as default')
}
icon={isDefault ? 'star' : 'favorite'}
onClick={onSetDefault}
/>
<Menu.Item label={t('alerting.saved-searches.rename', 'Rename')} icon="pen" onClick={onRename} />
<Menu.Divider />
<Menu.Item
label={t('alerting.saved-searches.delete', 'Delete')}
icon="trash-alt"
destructive
onClick={onDelete}
/>
</Menu>
);
return (
<Dropdown overlay={menu} placement="bottom-end" root={portalRoot ?? undefined}>
<IconButton
name="ellipsis-v"
aria-label={t('alerting.saved-searches.actions-aria-label', 'Actions')}
variant="secondary"
size="md"
disabled={isDisabled}
/>
</Dropdown>
);
}
// ============================================================================
// 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',
}),
};
}

View file

@ -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
### `<SavedSearches />`
The main component that renders a button and dropdown for managing saved searches.
```tsx
import { SavedSearches } from './SavedSearches';
<SavedSearches
savedSearches={savedSearches}
currentSearchQuery={searchQuery}
onSave={handleSave}
onRename={handleRename}
onDelete={handleDelete}
onApply={handleApply}
onSetDefault={handleSetDefault}
/>;
```
#### 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<void \| ValidationError>` | Yes | Called when user saves a new search |
| `onRename` | `(id: string, newName: string) => Promise<void \| ValidationError>` | Yes | Called when user renames a search |
| `onDelete` | `(id: string) => Promise<void>` | Yes | Called when user deletes a search |
| `onApply` | `(search: SavedSearch) => void` | Yes | Called when user applies a search |
| `onSetDefault` | `(id: string \| null) => Promise<void>` | 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<void \| ValidationError>` | Save a new search |
| `renameSearch` | `(id, newName) => Promise<void \| ValidationError>` | Rename an existing search |
| `deleteSearch` | `(id) => Promise<void>` | Delete a search |
| `setDefaultSearch` | `(id \| null) => Promise<void>` | 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 (
<SavedSearches
savedSearches={savedSearches}
currentSearchQuery={filterState.searchQuery}
onSave={saveSearch}
onRename={renameSearch}
onDelete={deleteSearch}
onApply={handleApply}
onSetDefault={setDefaultSearch}
/>
);
}
```
### Feature Toggle
The feature is gated behind the `alertingSavedSearches` feature toggle:
```tsx
import { shouldUseSavedSearches } from '../../featureToggles';
function MyComponent() {
const savedSearchesEnabled = shouldUseSavedSearches();
return (
<>
{savedSearchesEnabled && <SavedSearches ... />}
</>
);
}
```
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 (
<Wrapper>
<AppNotificationList />
{children}
</Wrapper>
);
};
}
// 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)

View file

@ -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(<SavedSearches {...defaultProps} savedSearches={[]} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} currentSearchQuery="" />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} />);
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(<SavedSearches {...defaultProps} currentSearchQuery="state:pending" />);
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(<SavedSearches {...defaultProps} savedSearches={searchesWithEmptyQuery} />);
await user.click(ui.savedSearchesButton.get());
expect(await screen.findByText('Empty Search')).toBeInTheDocument();
});
});
});

View file

@ -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
* <SavedSearches
* savedSearches={savedSearches}
* currentSearchQuery={searchQuery}
* onSave={handleSave}
* onRename={handleRename}
* onDelete={handleDelete}
* onApply={handleApply}
* onSetDefault={handleSetDefault}
* />
* ```
*/
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<void>;
/** Callback to rename an existing search. Throws ValidationError on failure. */
onRename: (id: string, newName: string) => Promise<void>;
/** Callback to delete a search */
onDelete: (id: string) => Promise<void>;
/** 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<void>;
/** 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<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(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<void> => {
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<void> => {
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 = (
<div
ref={dialogRef}
className={styles.dropdown}
role="dialog"
aria-label={t('alerting.saved-searches.dropdown-aria-label', 'Saved searches')}
tabIndex={-1}
>
<ListMode
searches={savedSearches}
hasSearches={hasSearches}
canSave={canSave}
activeAction={activeAction}
saveButtonRef={saveButtonRef}
isLoading={isLoading}
onStartSave={handleStartSave}
onSaveComplete={handleSaveComplete}
onCancelSave={handleCancelSave}
onApply={handleApply}
onStartRename={handleStartRename}
onCancelRename={handleCancelRename}
onRenameComplete={handleRenameComplete}
onStartDelete={handleStartDelete}
onCancelDelete={handleCancelDelete}
onDeleteConfirm={handleDeleteConfirm}
onSetDefault={handleSetDefault}
savedSearches={savedSearches}
menuPortalRoot={dialogRef.current}
/>
</div>
);
return (
<PopupCard
content={content}
placement="bottom-end"
showOn="click"
isOpen={isOpen}
onClose={handleClose}
onToggle={handleToggle}
disableBlur
>
<Button
variant="secondary"
icon="bookmark"
disabled={isLoading}
aria-label={buttonLabel}
aria-expanded={isOpen}
className={className}
>
{buttonLabel}
</Button>
</PopupCard>
);
}
// ============================================================================
// List Mode (shows saved searches or empty state)
// ============================================================================
interface ListModeProps {
searches: SavedSearch[];
hasSearches: boolean;
canSave: boolean;
activeAction: ActiveAction;
saveButtonRef: React.RefObject<HTMLButtonElement>;
isLoading: boolean;
onStartSave: () => void;
/** Callback to complete save. Throws ValidationError on validation failure. */
onSaveComplete: (name: string) => Promise<void>;
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<void>;
onStartDelete: (id: string) => void;
onCancelDelete: () => void;
onDeleteConfirm: (id: string) => Promise<void>;
onSetDefault: (id: string | null) => Promise<void>;
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 (
<Box padding={2} display="flex" justifyContent="center" alignItems="center">
<Spinner size="lg" />
</Box>
);
}
return (
<Stack direction="column" gap={1}>
{/* 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
<div className={styles.item} onClick={(e) => e.stopPropagation()}>
<InlineSaveInput onSave={onSaveComplete} onCancel={onCancelSave} savedSearches={savedSearches} />
</div>
) : (
<Box display="flex" justifyContent="flex-end">
<Button
ref={saveButtonRef}
variant="secondary"
icon="plus"
onClick={(e) => {
e.stopPropagation(); // Prevent dropdown from closing
onStartSave();
}}
disabled={!canSave || isActionActive}
title={
!canSave ? t('alerting.saved-searches.save-disabled-tooltip', 'Enter a search query first') : undefined
}
>
<Trans i18nKey="alerting.saved-searches.save-current-search">Save current search</Trans>
</Button>
</Box>
)}
{/* Empty state or list */}
{!hasSearches ? (
<EmptyState />
) : (
<Stack direction="column" gap={0.5}>
<div
className={styles.list}
role="list"
aria-label={t('alerting.saved-searches.list-aria-label', 'Saved searches list')}
>
{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 (
<SavedSearchItem
key={search.id}
search={search}
isRenaming={isRenaming}
isDeleting={isDeleting}
isDisabled={isItemDisabled}
onApply={() => 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}
/>
);
})}
</div>
</Stack>
)}
</Stack>
);
}
// ============================================================================
// Empty State
// ============================================================================
function EmptyState() {
return (
<Box padding={2} display="flex" justifyContent="center">
<Text color="secondary" italic>
<Trans i18nKey="alerting.saved-searches.empty-state">No saved searches yet</Trans>
</Text>
</Box>
);
}
// ============================================================================
// 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,
},
}),
};
}

View file

@ -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<typeof savedSearchSchema>;
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;
}

View file

@ -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);
}

View file

@ -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 (
<Wrapper>
<AppNotificationList />
{children}
</Wrapper>
);
};
}
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 });
});
});

View file

@ -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<SavedSearch[]> {
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<SavedSearch | null> {
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<void>;
/**
* 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<void>;
/**
* Delete a saved search by ID.
* @param id - The ID of the search to delete
*/
deleteSearch: (id: string) => Promise<void>;
/**
* Set or clear the default search.
* @param id - The ID to set as default, or null to clear
*/
setDefaultSearch: (id: string | null) => Promise<void>;
}
/**
* 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<SavedSearch[]>([]);
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<void> => {
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<void> => {
// 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<void> => {
// 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<void> => {
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<void> => {
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 });
}

View file

@ -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",