From 56c8e53a99f5b3bdd8d0cd4d22cd6b0771ea7176 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:04:00 +0100 Subject: [PATCH] Alerting: Improved filters part 2 (#109738) * refactor: split out form component into separate functions * add storybook variation for infoOption in dropdown * add tracking to search input for v2 view, tidy up tracking functions * add tests for filter tracking * refactor: boy-scouting V1 filter * move FiltersV2 to rule-list directory * fix lint issue * resolve PR comments round 1 * resolve PR comments round 2- update file locations * generate apis * fix tests * fix lint issue * fix imports --- .../Combobox/MultiCombobox.story.tsx | 25 + .../features/alerting/unified/Analytics.ts | 90 +-- .../rules/Filter/RulesFilter.v1.tsx | 57 +- .../rules/Filter/RulesFilter.v2.tsx | 623 --------------- .../unified/rule-list/RuleList.v2.tsx | 2 +- .../filter}/RulesFilter.test.tsx | 6 +- .../filter}/RulesFilter.tsx | 4 +- .../filter/RulesFilter.v2.test.tsx} | 96 ++- .../rule-list/filter/RulesFilter.v2.tsx | 753 ++++++++++++++++++ .../unified/rule-list/filter/types.ts | 17 + .../Filter => rule-list/filter}/utils.ts | 6 +- 11 files changed, 955 insertions(+), 724 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx rename public/app/features/alerting/unified/{components/rules => rule-list/filter}/RulesFilter.test.tsx (91%) rename public/app/features/alerting/unified/{components/rules/Filter => rule-list/filter}/RulesFilter.tsx (77%) rename public/app/features/alerting/unified/{components/rules/RulesFilterV2.test.tsx => rule-list/filter/RulesFilter.v2.test.tsx} (76%) create mode 100644 public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/types.ts rename public/app/features/alerting/unified/{components/rules/Filter => rule-list/filter}/utils.ts (91%) diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx index fd015178845..b3794bf3fa9 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx @@ -66,6 +66,31 @@ export const Basic: Story = { }, }; +export const WithInfoOption: Story = { + name: 'With infoOption', + args: { + ...commonArgs, + options: [ + ...commonArgs.options, + { label: 'Can’t find your country? Select “Other” or contact an admin', value: '__INFO__', infoOption: true }, + ], + }, + render: (args) => { + const [{ value }, setArgs] = useArgs(); + + return ( + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + ); + }, +}; + export const AutoSize: Story = { args: { ...commonArgs, width: 'auto', minWidth: 20 }, render: (args) => { diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 2f139066a5b..ec91b28d348 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,4 +1,4 @@ -import { isEmpty, pickBy } from 'lodash'; +import { pickBy } from 'lodash'; import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; @@ -7,9 +7,9 @@ import { RuleNamespace } from '../../../types/unified-alerting'; import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal'; -import { AdvancedFilters } from './components/rules/Filter/RulesFilter.v2'; import { FilterType } from './components/rules/central-state-history/EventListSceneObject'; -import { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser'; +import { AdvancedFilters } from './rule-list/filter/types'; +import { RulesFilter } from './search/rulesSearchParser'; import { RuleFormType } from './types/rule-form'; export const LogMessages = { @@ -245,44 +245,6 @@ export const trackImportToGMAError = async (payload: { importSource: 'yaml' | 'd reportInteraction('grafana_alerting_import_to_gma_error', { ...payload }); }; -interface RulesSearchInteractionPayload { - filter: string; - triggeredBy: 'typing' | 'component'; -} - -function trackRulesSearchInteraction(payload: RulesSearchInteractionPayload) { - reportInteraction('grafana_alerting_rules_search', { ...payload }); -} - -export function trackRulesSearchInputInteraction({ oldQuery, newQuery }: { oldQuery: string; newQuery: string }) { - try { - const oldFilter = getSearchFilterFromQuery(oldQuery); - const newFilter = getSearchFilterFromQuery(newQuery); - - const oldFilterTerms = extractFilterKeys(oldFilter); - const newFilterTerms = extractFilterKeys(newFilter); - - const newTerms = newFilterTerms.filter((term) => !oldFilterTerms.includes(term)); - newTerms.forEach((term) => { - trackRulesSearchInteraction({ filter: term, triggeredBy: 'typing' }); - }); - } catch (e: unknown) { - if (e instanceof Error) { - logError(e); - } - } -} - -function extractFilterKeys(filter: RulesFilter) { - return Object.entries(filter) - .filter(([_, value]) => !isEmpty(value)) - .map(([key]) => key); -} - -export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter) { - trackRulesSearchInteraction({ filter, triggeredBy: 'component' }); -} - export function trackRulesListViewChange(payload: { view: string }) { reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); } @@ -335,26 +297,64 @@ export function trackFilterButtonClick() { reportInteraction('grafana_alerting_filter_button_click'); } +export function trackAlertRuleFilterEvent( + payload: + | { filterMethod: 'search-input'; filter: RulesFilter } + | { filterMethod: 'filter-component'; filter: keyof RulesFilter } +) { + if (payload.filterMethod === 'search-input') { + const meaningfulValues = filterMeaningfulValues(payload.filter); + reportInteraction('grafana_alerting_rules_filter', { ...meaningfulValues, filterMethod: 'search-input' }); + return; + } + reportInteraction('grafana_alerting_rules_filter', { filter: payload.filter, filterMethod: 'filter-component' }); +} + +export function trackRulesSearchInputCleared(prev: string, next: string) { + // Only report an explicit clear action when transitioning from non-empty to empty + if (prev !== '' && next === '') { + reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'search-input' }); + } +} + export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { // Filter out empty/default values before tracking - const meaningfulValues = pickBy(payload, (value, key) => { + const meaningfulValues = filterMeaningfulValues(payload, { pluginsFilterEnabled }); + + reportInteraction('grafana_alerting_rules_filter', { + ...meaningfulValues, + filterMethod: 'filter-component', + }); +} + +function filterMeaningfulValues( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj: Record, + opts?: { pluginsFilterEnabled?: boolean } +) { + const { pluginsFilterEnabled = true } = opts ?? {}; + return pickBy(obj, (value, key) => { if (value === null || value === undefined || value === '') { return false; } if (Array.isArray(value) && value.length === 0) { return false; } + if (value === '*') { + return false; + } if (key === 'plugins' && !pluginsFilterEnabled) { return false; } + if (key === 'plugins' && value === 'show') { + return false; + } return true; }); - - reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues); } export function trackFilterButtonClearClick() { - reportInteraction('grafana_alerting_filter_button_clear_click'); + reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'filter-component' }); } export type AlertRuleTrackingProps = { diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index d58b0a5abdf..90e9d93a41f 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -11,20 +11,15 @@ import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types/accessControl'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; -import { - LogMessages, - logInfo, - trackRulesSearchComponentInteraction, - trackRulesSearchInputInteraction, -} from '../../../Analytics'; +import { LogMessages, logInfo, trackAlertRuleFilterEvent } from '../../../Analytics'; import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; -import { RuleHealth } from '../../../search/rulesSearchParser'; +import { RulesFilterProps } from '../../../rule-list/filter/RulesFilter'; +import { RuleHealth, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; -import { RulesFilterProps } from './RulesFilter'; import { RulesViewModeSelector } from './RulesViewModeSelector'; const RuleTypeOptions: SelectableValue[] = [ @@ -79,33 +74,27 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: }); setFilterKey((key) => key + 1); - trackRulesSearchComponentInteraction('dataSourceNames'); + trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: 'dataSourceNames' }); }; - const handleDashboardChange = (dashboardUid: string | undefined) => { - updateFilters({ ...filterState, dashboardUid }); - trackRulesSearchComponentInteraction('dashboardUid'); - }; + type Filters = typeof filterState; + + const updateAndTrack = + (key: K) => + (value: Filters[K]) => { + updateFilters({ ...filterState, [key]: value }); + trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: key }); + }; const clearDataSource = () => { updateFilters({ ...filterState, dataSourceNames: [] }); setFilterKey((key) => key + 1); }; + // Note: keep explicit logging for alert state filter clicks const handleAlertStateChange = (value: PromAlertingRuleState) => { logInfo(LogMessages.clickingAlertStateFilters); - updateFilters({ ...filterState, ruleState: value }); - trackRulesSearchComponentInteraction('ruleState'); - }; - - const handleRuleTypeChange = (ruleType: PromRuleType) => { - updateFilters({ ...filterState, ruleType }); - trackRulesSearchComponentInteraction('ruleType'); - }; - - const handleRuleHealthChange = (ruleHealth: RuleHealth) => { - updateFilters({ ...filterState, ruleHealth }); - trackRulesSearchComponentInteraction('ruleHealth'); + updateAndTrack('ruleState')(value); }; const handleClearFiltersClick = () => { @@ -116,8 +105,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: }; const handleContactPointChange = (contactPoint: string) => { - updateFilters({ ...filterState, contactPoint }); - trackRulesSearchComponentInteraction('contactPoint'); + updateAndTrack('contactPoint')(contactPoint); }; const searchIcon = ; @@ -190,7 +178,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: inputId="filters-dashboard-picker" key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'} value={filterState.dashboardUid} - onChange={(value) => handleDashboardChange(value?.uid)} + onChange={(value) => updateAndTrack('dashboardUid')(value?.uid)} isClearable cacheOptions /> @@ -210,7 +198,11 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: - +
{canRenderContactPointSelector && ( @@ -271,7 +263,10 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: onSubmit={handleSubmit((data) => { setSearchQuery(data.searchQuery); searchQueryRef.current?.blur(); - trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); + trackAlertRuleFilterEvent({ + filterMethod: 'search-input', + filter: getSearchFilterFromQuery(data.searchQuery), + }); })} > (null); - const { pluginsFilterEnabled } = usePluginsFilterStatus(); - - // 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 { setValue, watch, getValues, handleSubmit } = useForm({ - defaultValues: { - query: searchQuery, - }, - }); - - useEffect(() => { - setValue('query', searchQuery); - }, [searchQuery, setValue]); - - const submitHandler: SubmitHandler = (values: SearchQueryForm) => { - const parsedFilter = getSearchFilterFromQuery(values.query); - updateFilters(parsedFilter); - }; - - const handleAdvancedFilters: SubmitHandler = (values) => { - const newFilter = formAdvancedFiltersToRuleFilter(values); - updateFilters(newFilter); - - const newSearchQuery = applySearchFilterToQuery('', newFilter); - setSearchQuery(newSearchQuery); - - trackFilterButtonApplyClick(values, pluginsFilterEnabled); - setIsPopupOpen(false); // Should close popup after applying filters? - }; - - const handleClearFilters = () => { - updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); - setSearchQuery(undefined); - }; - - const handleOnToggle = () => { - trackFilterButtonClick(); - setIsPopupOpen(!isPopupOpen); - }; - - // Handle outside clicks to close the popup - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { - // Check if click is on a portal element (combobox dropdown) - if (event.target instanceof Element) { - const isPortalClick = - event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); - - if (!isPortalClick) { - setIsPopupOpen(false); - } - } else { - setIsPopupOpen(false); - } - } - }; - - if (isPopupOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isPopupOpen]); - - const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); - return ( -
{}}> - - - - - setValue('query', string)} - onBlur={() => { - const currentQuery = getValues('query'); - const parsedFilter = getSearchFilterFromQuery(currentQuery); - updateFilters(parsedFilter); - }} - value={watch('query')} - /> - - {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} - setIsPopupOpen(false)} - onToggle={handleOnToggle} - content={ - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - } - }} - role="dialog" - aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} - tabIndex={-1} - > - -
- } - > - -
- -
-
-
- ); -} - -interface FilterOptionsProps { - onSubmit: SubmitHandler; - onClear: () => void; - pluginsFilterEnabled: boolean; -} - -const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { - const styles = useStyles2(getStyles); - const theme = useStyles2((theme) => theme); - const { filterState } = useRulesFilter(); - const isManualResetRef = useRef(false); - - // Create portal container to render dropdowns above the popup modal - const portalContainer = usePortalContainer(theme.zIndex.portal + 100); - - const defaultValues = searchQueryToDefaultValues(filterState); - - // Fetch namespace and group data from all sources (optimized for filter UI) - const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = - useNamespaceAndGroupOptions(); - - const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); - - // Create label options for the multi-select dropdown - const dataSourceOptions = useAlertingDataSourceOptions(); - - // turn the filterState into form default values - const { handleSubmit, reset, register, control } = useForm({ - defaultValues, - }); - - // Update form values when filterState changes (e.g., when popup reopens) - useEffect(() => { - // Skip if we're in the middle of a manual reset - if (isManualResetRef.current) { - isManualResetRef.current = false; - return; - } - - const newDefaultValues = searchQueryToDefaultValues(filterState); - reset(newDefaultValues); - }, [filterState, reset]); - - const submitAdvancedFilters = handleSubmit(onSubmit); - - return ( -
{ - isManualResetRef.current = true; - reset(emptyAdvancedFilters); - trackFilterButtonClearClick(); - onClear(); - }} - > - -
- - - - ( - field.onChange(selections.map((s) => s.value))} - placeholder={ - isLoadingGrafanaLabels - ? t('common.loading', 'Loading...') - : t('alerting.rules-filter.placeholder-labels', 'Select labels') - } - loading={isLoadingGrafanaLabels} - disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} - portalContainer={portalContainer} - width="auto" - minWidth={40} - maxWidth={80} - /> - )} - /> - - { - return ( - - placeholder={namespacePlaceholder} - options={namespaceOptions} - onChange={(option) => field.onChange(option?.value || null)} - value={field.value} - loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || namespaceOptions.length === 0} - isClearable - portalContainer={portalContainer} - /> - ); - }} - /> - - { - return ( - - placeholder={groupPlaceholder} - options={allGroupNames.map((name) => ({ label: name, value: name }))} - onChange={(option) => field.onChange(option?.value || null)} - value={field.value} - loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || allGroupNames.length === 0} - isClearable - portalContainer={portalContainer} - /> - ); - }} - /> -
- } - > - - -
- - ( - field.onChange(selections.map((s) => s.value))} - placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} - portalContainer={portalContainer} - width="auto" - minWidth={40} - maxWidth={80} - /> - )} - /> - {canRenderContactPointSelector && ( - <> - - { - return ( - { - field.onChange(contactPoint?.spec.title || null); - }} - portalContainer={portalContainer} - /> - ); - }} - /> - - )} - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, - { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, - { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, - { - label: t('alerting.rules.state.recovering', 'Recovering'), - value: PromAlertingRuleState.Recovering, - }, - { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, - { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, - { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, - { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - {pluginsFilterEnabled && ( - <> - - ( - - options={[ - { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, - { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - )} - - - - - - - - ); -}; - -function SearchQueryHelp() { - const styles = useStyles2(helpStyles); - - return ( -
-
- - Search syntax allows to query alert rules by the parameters defined below. - -
-
-
-
- Filter type -
-
- Expression -
- - - - - - - - - - -
-
- ); -} - -function HelpRow({ title, expr }: { title: string; expr: string }) { - const styles = useStyles2(helpStyles); - - return ( - <> -
{title}
- {expr} - - ); -} - -const helpStyles = (theme: GrafanaTheme2) => ({ - grid: css({ - display: 'grid', - gridTemplateColumns: 'max-content auto', - gap: theme.spacing(1), - alignItems: 'center', - }), - code: css({ - display: 'block', - textAlign: 'center', - }), -}); - -function getStyles(theme: GrafanaTheme2) { - return { - content: css({ - padding: theme.spacing(1), - }), - grid: css({ - display: 'grid', - gridTemplateColumns: 'auto 1fr', - alignItems: 'center', - gap: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 7f0ae393755..7018bc3f9c9 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -7,7 +7,6 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; -import RulesFilter from '../components/rules/Filter/RulesFilter'; import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; @@ -17,6 +16,7 @@ import { isAdmin } from '../utils/misc'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; import { RuleListPageTitle } from './RuleListPageTitle'; +import RulesFilter from './filter/RulesFilter'; function RuleList() { const { filterState } = useRulesFilter(); diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx similarity index 91% rename from public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx index 720e0ed1275..a0d383f71c6 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx @@ -7,13 +7,13 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import * as analytics from '../../Analytics'; import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; -import RulesFilter from './Filter/RulesFilter'; +import RulesFilter from './RulesFilter'; setupMswServer(); jest.spyOn(analytics, 'logInfo'); -jest.mock('./MultipleDataSourcePicker', () => { - const original = jest.requireActual('./MultipleDataSourcePicker'); +jest.mock('../../components/rules/MultipleDataSourcePicker', () => { + const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { ...original, MultipleDataSourcePicker: () => null, diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx similarity index 77% rename from public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx index 3eea628410f..c3a159c7ab2 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx @@ -2,8 +2,8 @@ import { Suspense, lazy } from 'react'; import { config } from '@grafana/runtime'; -import RulesFilterV1 from './RulesFilter.v1'; -import { SupportedView } from './RulesViewModeSelector'; +import RulesFilterV1 from '../../components/rules/Filter/RulesFilter.v1'; +import { SupportedView } from '../../components/rules/Filter/RulesViewModeSelector'; const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); diff --git a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx similarity index 76% rename from public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx index ecff8085f42..18d68d0f670 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx @@ -13,8 +13,12 @@ import { useRulesFilter } from '../../hooks/useFilteredRules'; import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; +import RulesFilter from './RulesFilter'; + // 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 +const RulesFilterV2 = require('./RulesFilter.v2').default; let mockFilterState: RulesFilterType = { ruleName: '', @@ -40,9 +44,6 @@ jest.mock('../../hooks/useFilteredRules', () => ({ })), })); -import RulesFilter from './Filter/RulesFilter'; -import RulesFilterV2 from './Filter/RulesFilter.v2'; - const useRulesFilterMock = useRulesFilter as jest.MockedFunction; setupMswServer(); @@ -50,6 +51,8 @@ setupMswServer(); jest.spyOn(analytics, 'trackFilterButtonClick'); jest.spyOn(analytics, 'trackFilterButtonApplyClick'); jest.spyOn(analytics, 'trackFilterButtonClearClick'); +jest.spyOn(analytics, 'trackAlertRuleFilterEvent'); +jest.spyOn(analytics, 'trackRulesSearchInputCleared'); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -61,8 +64,8 @@ jest.mock('@grafana/runtime', () => ({ }), })); -jest.mock('./MultipleDataSourcePicker', () => { - const original = jest.requireActual('./MultipleDataSourcePicker'); +jest.mock('../../components/rules/MultipleDataSourcePicker', () => { + const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { ...original, MultipleDataSourcePicker: () => null, @@ -119,10 +122,25 @@ beforeEach(() => { labels: [], }; mockSearchQuery = ''; - mockUpdateFilters.mockClear(); - mockSetSearchQuery.mockClear(); - mockClearAll.mockClear(); + // Fully reset mock implementations between tests to avoid leakage across cases + mockUpdateFilters.mockReset(); + mockSetSearchQuery.mockReset(); + mockClearAll.mockReset(); + mockUpdateFilters.mockImplementation(() => {}); mockSetSearchQuery.mockImplementation(() => {}); + mockClearAll.mockImplementation(() => {}); + + // Restore the default implementation of the hook to use current mock variables + useRulesFilterMock.mockReset(); + useRulesFilterMock.mockImplementation(() => ({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + })); // Reset plugin components hook to default (no plugins) setPluginComponentsHook(() => ({ @@ -209,21 +227,33 @@ describe('RulesFilterV2', () => { }); it('Should populate search field with query string when filters are applied via rule name', async () => { - const { user } = render(); + const { user, rerender } = render(); await user.click(ui.filterButton.get()); await user.type(ui.ruleNameInput.get(), 'test'); - // Mock the setSearchQuery to update mockSearchQuery - mockSetSearchQuery.mockImplementation((newQuery: string | undefined) => { - mockSearchQuery = newQuery ?? ''; + // Mock updateFilters to update the search query as the implementation does + mockUpdateFilters.mockImplementation(() => { + mockSearchQuery = 'rule:test'; }); await user.click(ui.applyButton.get()); - // Check that setSearchQuery was called with the expected query - expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test'); + // Update the mock to return the new search query and re-render + useRulesFilterMock.mockReturnValue({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + }); + rerender(); + + // The search input should reflect the updated query string + expect(ui.searchInput.get()).toHaveValue('rule:test'); }); it('Should parse search query and call updateFilters when user types directly in search field', async () => { @@ -294,7 +324,7 @@ describe('RulesFilterV2', () => { // Permission is already mocked to true at module level const { user } = render(); await user.click(ui.filterButton.get()); - expect(screen.getByText('Contact point')).toBeInTheDocument(); + expect(await screen.findByText('Contact point')).toBeInTheDocument(); }); it('Should show plugin filter when plugins are enabled', async () => { @@ -306,7 +336,7 @@ describe('RulesFilterV2', () => { const { user } = render(); await user.click(ui.filterButton.get()); - expect(screen.getByText('Plugin rules')).toBeInTheDocument(); + expect(await screen.findByText('Plugin rules')).toBeInTheDocument(); }); it('Should hide plugin filter when no plugins are available', async () => { @@ -349,6 +379,40 @@ describe('RulesFilterV2', () => { expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); }); + it('Should track search input submit with parsed filter payload', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'rule:test state:firing'); + await user.keyboard('{Enter}'); + + expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); + const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; + expect(callArg.filterMethod).toBe('search-input'); + expect(callArg.filter).toMatchObject({ ruleName: 'test', ruleState: 'firing' }); + }); + + it('Should track search input blur with parsed filter payload', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'state:firing'); + await user.click(document.body); + + expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); + const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; + expect(callArg.filterMethod).toBe('search-input'); + expect(callArg.filter).toMatchObject({ ruleState: 'firing' }); + }); + + it('Should track search input clear when input transitions to empty', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'abc'); + expect(ui.searchInput.get()).toHaveValue('abc'); + await user.clear(ui.searchInput.get()); + + expect(analytics.trackRulesSearchInputCleared).toHaveBeenCalled(); + }); + it('Should not track filter button click when filter button is clicked to close popup', async () => { const { user } = render(); diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx new file mode 100644 index 00000000000..69b3b865a43 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -0,0 +1,753 @@ +import { css } from '@emotion/css'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; + +import { ContactPointSelector } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + Box, + Button, + Combobox, + FilterInput, + Icon, + Input, + Label, + MultiCombobox, + RadioButtonGroup, + Stack, + Tooltip, + useStyles2, + useTheme2, +} from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import type { AdvancedFilters } from 'app/features/alerting/unified/rule-list/filter/types'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { + trackAlertRuleFilterEvent, + trackFilterButtonApplyClick, + trackFilterButtonClearClick, + trackFilterButtonClick, + trackRulesSearchInputCleared, +} from '../../Analytics'; +import { PopupCard } from '../../components/HoverCard'; +import { RulesViewModeSelector } from '../../components/rules/Filter/RulesViewModeSelector'; +import { + useAlertingDataSourceOptions, + useLabelOptions, + useNamespaceAndGroupOptions, +} from '../../components/rules/Filter/useRuleFilterAutocomplete'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { RuleHealth, getSearchFilterFromQuery } from '../../search/rulesSearchParser'; + +import { RulesFilterProps } from './RulesFilter'; +import { + emptyAdvancedFilters, + formAdvancedFiltersToRuleFilter, + searchQueryToDefaultValues, + usePluginsFilterStatus, + usePortalContainer, +} from './utils'; + +const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); + +type SearchQueryForm = { + query: string; +}; + +export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { + const styles = useStyles2(getStyles); + + const [isPopupOpen, setIsPopupOpen] = useState(false); + const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter(); + const popupRef = useRef(null); + const { pluginsFilterEnabled } = usePluginsFilterStatus(); + + // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters + const { control, setValue, handleSubmit } = useForm({ + defaultValues: { + query: searchQuery, + }, + }); + + useEffect(() => { + setValue('query', searchQuery); + }, [searchQuery, setValue]); + + const submitHandler: SubmitHandler = (values: SearchQueryForm) => { + const parsedFilter = getSearchFilterFromQuery(values.query); + trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); + updateFilters(parsedFilter); + }; + + const handleAdvancedFilters: SubmitHandler = (values) => { + const newFilter = formAdvancedFiltersToRuleFilter(values); + updateFilters(newFilter); + + trackFilterButtonApplyClick(values, pluginsFilterEnabled); + setIsPopupOpen(false); // Should close popup after applying filters? + }; + + const handleClearFilters = () => { + updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); + setSearchQuery(undefined); + }; + + const handleOnToggle = () => { + trackFilterButtonClick(); + setIsPopupOpen(!isPopupOpen); + }; + + // Handle outside clicks to close the popup + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { + // Check if click is on a portal element (combobox dropdown) + if (event.target instanceof Element) { + const isPortalClick = + event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); + + if (!isPortalClick) { + setIsPopupOpen(false); + } + } else { + setIsPopupOpen(false); + } + } + }; + + if (isPopupOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isPopupOpen]); + + const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); + return ( +
{}}> + + + + + ( + { + trackRulesSearchInputCleared(field.value, next); + field.onChange(next); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === 'NumpadEnter') { + event.preventDefault(); + handleSubmit(submitHandler)(); + } + }} + onBlur={() => { + const currentQuery = field.value; + const parsedFilter = getSearchFilterFromQuery(currentQuery); + trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); + updateFilters(parsedFilter); + }} + value={field.value} + /> + )} + /> + + {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} + setIsPopupOpen(false)} + onToggle={handleOnToggle} + content={ + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + role="dialog" + aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} + tabIndex={-1} + > + +
+ } + > + +
+ +
+
+
+ ); +} + +interface FilterOptionsProps { + onSubmit: SubmitHandler; + onClear: () => void; + pluginsFilterEnabled: boolean; +} + +const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const { filterState } = useRulesFilter(); + const isManualResetRef = useRef(false); + + // Create portal container to render dropdowns above the popup modal + const portalContainer = usePortalContainer(theme.zIndex.portal + 100); + + const defaultValues = searchQueryToDefaultValues(filterState); + + // Fetch namespace and group data from all sources (optimized for filter UI) + const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = + useNamespaceAndGroupOptions(); + + const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); + + // Create label options for the multi-select dropdown + const dataSourceOptions = useAlertingDataSourceOptions(); + + // turn the filterState into form default values + const methods = useForm({ + defaultValues, + }); + const { handleSubmit, reset } = methods; + + // Update form values when filterState changes (e.g., when popup reopens) + useEffect(() => { + // Skip if we're in the middle of a manual reset + if (isManualResetRef.current) { + isManualResetRef.current = false; + return; + } + + const newDefaultValues = searchQueryToDefaultValues(filterState); + reset(newDefaultValues); + }, [filterState, reset]); + + const submitAdvancedFilters = handleSubmit(onSubmit); + + return ( + +
{ + isManualResetRef.current = true; + reset(emptyAdvancedFilters); + trackFilterButtonClearClick(); + onClear(); + }} + > + +
+ + + + + + {canRenderContactPointSelector && } + + + + {pluginsFilterEnabled && } +
+ + + + +
+
+
+ ); +}; + +function RuleNameField() { + const { register } = useFormContext(); + return ( + <> + + + + ); +} + +function LabelsField({ + labelOptions, + isLoadingGrafanaLabels, + portalContainer, +}: { + labelOptions: Array<{ label?: string; value: string; infoOption?: boolean }>; + isLoadingGrafanaLabels: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={ + isLoadingGrafanaLabels + ? t('common.loading', 'Loading...') + : t('alerting.rules-filter.placeholder-labels', 'Select labels') + } + loading={isLoadingGrafanaLabels} + disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + + ); +} + +function NamespaceField({ + namespaceOptions, + namespacePlaceholder, + isLoadingNamespaces, + portalContainer, +}: { + namespaceOptions: Array<{ label?: string; value: string; description?: string }>; + namespacePlaceholder: string; + isLoadingNamespaces: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + + placeholder={namespacePlaceholder} + options={namespaceOptions} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || namespaceOptions.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function GroupField({ + allGroupNames, + groupPlaceholder, + isLoadingNamespaces, + portalContainer, +}: { + allGroupNames: string[]; + groupPlaceholder: string; + isLoadingNamespaces: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + + placeholder={groupPlaceholder} + options={allGroupNames.map((name) => ({ label: name, value: name }))} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || allGroupNames.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function DataSourceNamesField({ + dataSourceOptions, + portalContainer, +}: { + dataSourceOptions: Array<{ label?: string; value: string }>; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + + ); +} + +function ContactPointField({ portalContainer }: { portalContainer?: HTMLElement }) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + { + field.onChange(contactPoint?.spec.title || null); + }} + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function RuleStateField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, + { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, + { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, + { label: t('alerting.rules.state.recovering', 'Recovering'), value: PromAlertingRuleState.Recovering }, + { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function RuleTypeField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, + { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function RuleHealthField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, + { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, + { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function PluginsField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, + { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); + + return ( +
+
+ + Search syntax allows to query alert rules by the parameters defined below. + +
+
+
+
+ Filter type +
+
+ Expression +
+ + + + + + + + + + +
+
+ ); +} + +function HelpRow({ title, expr }: { title: string; expr: string }) { + const styles = useStyles2(helpStyles); + + return ( + <> +
{title}
+ {expr} + + ); +} + +const helpStyles = (theme: GrafanaTheme2) => ({ + grid: css({ + display: 'grid', + gridTemplateColumns: 'max-content auto', + gap: theme.spacing(1), + alignItems: 'center', + }), + code: css({ + display: 'block', + textAlign: 'center', + }), +}); + +function getStyles(theme: GrafanaTheme2) { + return { + content: css({ + padding: theme.spacing(1), + }), + grid: css({ + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignItems: 'center', + gap: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/types.ts b/public/app/features/alerting/unified/rule-list/filter/types.ts new file mode 100644 index 00000000000..dbfa6ad59db --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/types.ts @@ -0,0 +1,17 @@ +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import type { RuleHealth } from '../../search/rulesSearchParser'; + +export type AdvancedFilters = { + namespace?: string | null; + groupName?: string | null; + ruleName?: string; + ruleType?: PromRuleType | '*'; + ruleState: PromAlertingRuleState | '*'; + dataSourceNames: string[]; + labels: string[]; + ruleHealth?: RuleHealth | '*'; + dashboardUid?: string; + plugins?: 'show' | 'hide'; + contactPoint?: string | null; +}; diff --git a/public/app/features/alerting/unified/components/rules/Filter/utils.ts b/public/app/features/alerting/unified/rule-list/filter/utils.ts similarity index 91% rename from public/app/features/alerting/unified/components/rules/Filter/utils.ts rename to public/app/features/alerting/unified/rule-list/filter/utils.ts index 049634634af..798a4b0bade 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/utils.ts +++ b/public/app/features/alerting/unified/rule-list/filter/utils.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react'; -import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; -import { RulesFilter } from '../../../search/rulesSearchParser'; +import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions'; +import { RulesFilter } from '../../search/rulesSearchParser'; -import { AdvancedFilters } from './RulesFilter.v2'; +import { AdvancedFilters } from './types'; export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { return {