From af68304af0eb80988fcc15888bc128f07056ce59 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:21:03 +0100 Subject: [PATCH] Alerting: Improve Alert Rules Filter (#109176) * big WIP * more WIP * leftover changes after merge * bug fix: prevent modal close onclick inside modal * add logic to filter by data sources * use MultiComboBox component instead of MultipleDataSourcePicker * add namespace and evaluation group filtering logic * remove header section of PopupCard * set max width on popup * add contact point field * add contact point logic and tooltip * add tracking to filter- track open, apply, clear and values submitted * add plugins show/hide field * add plugins show/hide field * add tests for new filter * test tracking works as expected * check v2 filter only shows if feature toggle is on * tidying up * fix lint errors * fix close-on-type bug * use ContactPointSelector component * fix close-on-click-outside issue * Add label dropdown logic * Add query string to search field * add test to check filtering by search field updates the filter popup values * clear search input onClear of filters * fix lint issues * add tooltips to search input and datasource fields * Sort typing in ContactPointSelector * update translation file * Add logic to group and list view buttons & refactoring * update failing test * update test mocks * fix failing tests * fix typecheck errors * resolve PR comments part 1 * update label dropdown to include info text * Translation extraction * resolving PR comments * move autocomplete logic to reusable hook * resolve PR comments * fix typecheck --------- Co-authored-by: Gilles De Mey Co-authored-by: Lauren Armstrong Co-authored-by: Lauren Armstrong Co-authored-by: Lauren Armstrong --- .../src/components/Combobox/Combobox.tsx | 12 +- .../src/components/Combobox/ComboboxList.tsx | 24 +- .../src/components/Combobox/MultiCombobox.tsx | 15 +- .../components/Combobox/getComboboxStyles.ts | 9 + .../src/components/Combobox/types.ts | 1 + .../features/alerting/unified/Analytics.ts | 29 +- .../alerting/unified/components/HoverCard.tsx | 47 +- .../components/rules/Filter/RulesFilter.tsx | 4 +- .../rules/Filter/RulesFilter.v1.tsx | 12 +- .../rules/Filter/RulesFilter.v2.tsx | 763 +++++++++++++----- .../rules/Filter/RulesViewModeSelector.tsx | 4 +- .../rules/Filter/useRuleFilterAutocomplete.ts | 186 +++++ .../unified/components/rules/Filter/utils.ts | 81 ++ .../components/rules/RulesFilterV2.test.tsx | 366 +++++++++ .../unified/hooks/useFilteredRules.ts | 6 +- public/locales/en-US/grafana.json | 59 +- 16 files changed, 1370 insertions(+), 248 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts create mode 100644 public/app/features/alerting/unified/components/rules/Filter/utils.ts create mode 100644 public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index 90865d8cb4f..b81d0e601fa 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -32,6 +32,10 @@ interface ComboboxStaticProps * Allows the user to set a value which is not in the list of options. */ createCustomValue?: boolean; + /** + * Custom container for rendering the dropdown menu via Portal + */ + portalContainer?: HTMLElement; /** * An array of options, or a function that returns a promise resolving to an array of options. @@ -131,6 +135,7 @@ export const Combobox = (props: ComboboxProps) => autoFocus, onBlur, disabled, + portalContainer, invalid, } = props; @@ -383,10 +388,13 @@ export const Combobox = (props: ComboboxProps) => 'data-testid': dataTestId, })} /> - +
({ className={cx( styles.option, !isMultiSelect && isOptionSelected(item) && styles.optionSelected, - highlightedIndex === virtualRow.index && styles.optionFocused + highlightedIndex === virtualRow.index && !item.infoOption && styles.optionFocused, + item.infoOption && styles.optionInfo )} {...getItemProps({ item: item, index: virtualRow.index, id: itemId, 'aria-describedby': groupHeaderId, + disabled: item.infoOption, })} > {isMultiSelect && (
- 0 && !allItemsSelected} - aria-labelledby={itemId} - onClick={(e) => { - e.stopPropagation(); - }} - /> + {!item.infoOption && ( + 0 && !allItemsSelected} + aria-labelledby={itemId} + onClick={(e) => { + e.stopPropagation(); + }} + /> + )}
)} diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index e6a3723191c..13dd02742e4 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -30,6 +30,7 @@ interface MultiComboboxBaseProps onChange: (option: Array>) => void; isClearable?: boolean; enableAllOption?: boolean; + portalContainer?: HTMLElement; } export type MultiComboboxProps = MultiComboboxBaseProps & AutoSizeConditionals; @@ -49,6 +50,7 @@ export const MultiCombobox = (props: MultiComboboxPro createCustomValue = false, 'aria-labelledby': ariaLabelledBy, 'data-testid': dataTestId, + portalContainer, } = props; const styles = useStyles2(getComboboxStyles); @@ -197,6 +199,11 @@ export const MultiCombobox = (props: MultiComboboxPro switch (type) { case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: + // Don't allow selection of info options + if (newSelectedItem?.infoOption) { + break; + } + // Handle All functionality if (newSelectedItem?.value === ALL_OPTION_VALUE) { // TODO: fix bug where if the search filtered items list is the @@ -204,12 +211,11 @@ export const MultiCombobox = (props: MultiComboboxPro const isAllFilteredSelected = selectedItems.length === options.length - 1; // if every option is already selected, clear the selection. - // otherwise, select all the options (excluding the first ALL_OTION) - const realOptions = options.slice(1); + // otherwise, select all the options (excluding the first ALL_OPTION and info options) + const realOptions = options.slice(1).filter((option) => !option.infoOption); let newSelectedItems = isAllFilteredSelected && inputValue === '' ? [] : realOptions; if (!isAllFilteredSelected && inputValue !== '') { - // Select all currently filtered items and deduplicate newSelectedItems = [...new Set([...selectedItems, ...realOptions])]; } @@ -329,12 +335,13 @@ export const MultiCombobox = (props: MultiComboboxPro
- +
diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index 14c87b124ec..15c30dadd82 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -141,6 +141,15 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { top: 0, }, }), + optionInfo: css({ + label: 'combobox-option-info', + color: theme.colors.text.disabled, + cursor: 'not-allowed', + pointerEvents: 'none', + '&:hover': { + background: 'transparent', + }, + }), clear: css({ label: 'combobox-clear', cursor: 'pointer', diff --git a/packages/grafana-ui/src/components/Combobox/types.ts b/packages/grafana-ui/src/components/Combobox/types.ts index 9bc5cde0204..537982d5865 100644 --- a/packages/grafana-ui/src/components/Combobox/types.ts +++ b/packages/grafana-ui/src/components/Combobox/types.ts @@ -5,4 +5,5 @@ export type ComboboxOption = { value: T; description?: string; group?: string; + infoOption?: boolean; }; diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index ebe07e43f9f..2f139066a5b 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,4 +1,4 @@ -import { isEmpty } from 'lodash'; +import { isEmpty, pickBy } from 'lodash'; import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; @@ -7,6 +7,7 @@ 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 { RuleFormType } from './types/rule-form'; @@ -330,6 +331,32 @@ export function trackFolderBulkActionsUnpauseFail() { reportInteraction('grafana_alerting_folder_bulk_actions_unpause_fail'); } +export function trackFilterButtonClick() { + reportInteraction('grafana_alerting_filter_button_click'); +} + +export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { + // Filter out empty/default values before tracking + const meaningfulValues = pickBy(payload, (value, key) => { + if (value === null || value === undefined || value === '') { + return false; + } + if (Array.isArray(value) && value.length === 0) { + return false; + } + if (key === 'plugins' && !pluginsFilterEnabled) { + return false; + } + return true; + }); + + reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues); +} + +export function trackFilterButtonClearClick() { + reportInteraction('grafana_alerting_filter_button_clear_click'); +} + export type AlertRuleTrackingProps = { user_id: number; grafana_version?: string; diff --git a/public/app/features/alerting/unified/components/HoverCard.tsx b/public/app/features/alerting/unified/components/HoverCard.tsx index 9e010873aaf..6c5577df37b 100644 --- a/public/app/features/alerting/unified/components/HoverCard.tsx +++ b/public/app/features/alerting/unified/components/HoverCard.tsx @@ -17,6 +17,10 @@ export interface PopupCardProps { showAfter?: number; arrow?: boolean; showOn?: 'click' | 'hover'; + disableBlur?: boolean; + isOpen?: boolean; + onClose?: () => void; + onToggle?: () => void; } export const PopupCard = ({ @@ -29,6 +33,10 @@ export const PopupCard = ({ wrapperClassName, disabled = false, showOn = 'hover', + disableBlur = false, + isOpen, + onClose, + onToggle, ...rest }: PopupCardProps) => { const popoverRef = useRef(null); @@ -52,19 +60,37 @@ export const PopupCard = ({ return ( {(showPopper, hidePopper, popperProps) => { + // Use manual control if provided, otherwise use internal state + const isManuallyControlled = isOpen !== undefined; + const shouldShow = isManuallyControlled ? isOpen : popperProps.show; + + const handleClose = () => { + if (onClose) { + onClose(); + } else { + hidePopper(); + } + }; + + const handleShow = () => { + if (!isManuallyControlled) { + showPopper(); + } + }; + // support hover and click interaction const onClickProps = { - onClick: showPopper, + onClick: onToggle || (isManuallyControlled ? handleClose : showPopper), }; const onHoverProps = { - onMouseLeave: hidePopper, - onMouseEnter: showPopper, + onMouseLeave: handleClose, + onMouseEnter: handleShow, }; const blurFocusProps = { - onBlur: hidePopper, - onFocus: showPopper, + onBlur: handleClose, + onFocus: handleShow, }; return ( @@ -72,26 +98,29 @@ export const PopupCard = ({ {popoverRef.current && ( )} {cloneElement(children, { ref: popoverRef, - onFocus: showPopper, - onBlur: hidePopper, + onFocus: handleShow, + onBlur: disableBlur ? undefined : handleClose, tabIndex: 0, // make sure we pass the correct interaction handlers here to the element we want to interact with ...(showOnHover ? onHoverProps : {}), - ...(showOnClick ? onClickProps : {}), + // Only add click handling if we have onToggle or not manually controlled + ...(showOnClick && (onToggle || !isManuallyControlled) ? onClickProps : {}), })} ); diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx index b904a5c1427..3eea628410f 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx @@ -7,13 +7,13 @@ import { SupportedView } from './RulesViewModeSelector'; const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); -interface RulesFilerProps { +export interface RulesFilterProps { onClear?: () => void; viewMode?: SupportedView; onViewModeChange?: (viewMode: SupportedView) => void; } -const RulesFilter = (props: RulesFilerProps) => { +const RulesFilter = (props: RulesFilterProps) => { const newView = config.featureToggles.alertingFilterV2; return {newView ? : }; }; 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 ca70ddaa984..d58b0a5abdf 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 @@ -24,7 +24,8 @@ import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; -import { RulesViewModeSelector, SupportedView } from './RulesViewModeSelector'; +import { RulesFilterProps } from './RulesFilter'; +import { RulesViewModeSelector } from './RulesViewModeSelector'; const RuleTypeOptions: SelectableValue[] = [ { label: 'Alert ', value: PromRuleType.Alerting }, @@ -37,15 +38,8 @@ const RuleHealthOptions: SelectableValue[] = [ { label: 'Error', value: RuleHealth.Error }, ]; -// Contact point selector is not supported in Alerting ListView V2 yet const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); -interface RulesFilerProps { - onClear?: () => void; - viewMode?: SupportedView; - onViewModeChange?: (viewMode: SupportedView) => void; -} - const RuleStateOptions = Object.entries(PromAlertingRuleState) .filter(([key, value]) => value !== PromAlertingRuleState.Unknown) // Exclude Unknown state from filter options .map(([key, value]) => ({ @@ -53,7 +47,7 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState) value, })); -const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilerProps) => { +const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilterProps) => { const styles = useStyles2(getStyles); const { pluginsFilterEnabled } = usePluginsFilterStatus(); const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter(); diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx index 91d76ce107f..8411da2e8d3 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx @@ -1,226 +1,623 @@ import { css } from '@emotion/css'; -import { useCallback, useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { ContactPointSelector } from '@grafana/alerting/unstable'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { - Badge, + Box, Button, - Grid, - IconButton, + Combobox, + FilterInput, + Icon, Input, - InteractiveTable, Label, + MultiCombobox, RadioButtonGroup, - Select, Stack, - Tab, - TabsBar, + Tooltip, useStyles2, } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; +import { trackFilterButtonApplyClick, trackFilterButtonClearClick, trackFilterButtonClick } from '../../../Analytics'; +import { useRulesFilter } from '../../../hooks/useFilteredRules'; +import { RuleHealth, applySearchFilterToQuery, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; import { PopupCard } from '../../HoverCard'; -import MoreButton from '../../MoreButton'; -type RulesFilterProps = { - onClear?: () => void; +import { RulesFilterProps } from './RulesFilter'; +import { RulesViewModeSelector } from './RulesViewModeSelector'; +import { + useAlertingDataSourceOptions, + useLabelOptions, + useNamespaceAndGroupOptions, +} from './useRuleFilterAutocomplete'; +import { + emptyAdvancedFilters, + formAdvancedFiltersToRuleFilter, + searchQueryToDefaultValues, + usePluginsFilterStatus, + usePortalContainer, +} from './utils'; + +const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); + +/** + * Custom hook that creates a DOM container for rendering dropdowns outside of popup stacking contexts. + * This prevents dropdowns from appearing behind modals/popups due to CSS stacking context limitations. + * + * @param zIndex - The z-index value for the portal container + * @returns HTMLDivElement container appended to document.body, or undefined during initial render + */ + +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; }; -type ActiveTab = 'custom' | 'saved'; +type SearchQueryForm = { + query: string; +}; -export default function RulesFilter({ onClear = () => {} }: RulesFilterProps) { +export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { const styles = useStyles2(getStyles); - const [activeTab, setActiveTab] = useState('custom'); - const filterOptions = useMemo(() => { - return ( - - {activeTab === 'custom' && } - {activeTab === 'saved' && } -
- } - header={ - - setActiveTab('custom')} - /> - setActiveTab('saved')} - /> - - } - > - - - ); - }, [activeTab, styles.content, styles.fixTabsMargin]); + 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 { 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} + > + +
+ } + > + +
+ +
- +
); } -const FilterOptions = () => { +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 ( - - - - - - - - - - + + ( + 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} + /> + )} + /> + + )} + + + + + -
+ ); }; -type TableColumns = { - name: string; - default?: boolean; -}; - -const SavedSearches = () => { - const applySearch = useCallback((name: string) => {}, []); +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); return ( - - - - columns={[ - { - id: 'name', - header: 'Saved search name', - cell: ({ row }) => ( - - {row.original.name} - {row.original.default ? ( - - ) : null} - - ), - }, - { - id: 'actions', - cell: ({ row }) => ( - - - - - ), - }, - ]} - data={[ - { - name: 'My saved search', - default: true, - }, - { - name: 'Another saved search', - }, - { - name: 'This one has a really long name and some emojis too 🥒', - }, - ]} - getRowId={(row) => row.name} - /> - - +
+
+ + 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), - maxWidth: 500, }), - fixTabsMargin: css({ - marginTop: theme.spacing(-1), + grid: css({ + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignItems: 'center', + gap: theme.spacing(2), }), }; } diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx index 661507d6ac8..f467a1395fa 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx @@ -13,8 +13,8 @@ export type SupportedView = 'list' | 'grouped'; type LegacySupportedView = 'list' | 'grouped' | 'state'; const ViewOptions: Array> = [ - { icon: 'folder', label: 'Grouped', value: 'grouped' }, - { icon: 'list-ul', label: 'List', value: 'list' }, + { icon: 'folder', value: 'grouped', label: 'Grouped' }, + { icon: 'list-ul', value: 'list', label: 'List' }, ]; interface RulesViewModeSelectorV2Props { diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts new file mode 100644 index 00000000000..8210080a1d8 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -0,0 +1,186 @@ +import { useMemo } from 'react'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { getDataSourceSrv } from '@grafana/runtime'; +import { ComboboxOption } from '@grafana/ui'; +import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { alertRuleApi } from '../../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi'; +import { prometheusApi } from '../../../api/prometheusApi'; +import { useGetLabelsFromDataSourceName } from '../../../components/rule-editor/useAlertRuleSuggestions'; +import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSources } from '../../../utils/datasource'; + +export function useNamespaceAndGroupOptions(): { + namespaceOptions: Array>; + allGroupNames: string[]; + isLoadingNamespaces: boolean; + namespacePlaceholder: string; + groupPlaceholder: string; +} { + const { currentData: grafanaPromRulesResponse, isLoading: isLoadingGrafanaPromRules } = + prometheusApi.endpoints.getGrafanaGroups.useQuery({ + limitAlerts: 0, + groupLimit: 1000, + }); + + // Transform Grafana groups to namespace structure + const grafanaPromRules = useMemo(() => { + const groups = grafanaPromRulesResponse?.data?.groups ?? []; + + const namespaceMap = new Map(); + groups.forEach((group) => { + const namespaceName = group.file || 'default'; + const existing = namespaceMap.get(namespaceName); + if (existing) { + existing.groups.push(group); + } else { + namespaceMap.set(namespaceName, { name: namespaceName, groups: [group] }); + } + }); + + return Array.from(namespaceMap.values()); + }, [grafanaPromRulesResponse]); + + const { isLoading: isLoadingGrafanaRulerRules } = alertRuleApi.endpoints.rulerRules.useQuery({ + rulerConfig: GRAFANA_RULER_CONFIG, + }); + + const externalDataSources = useMemo(getRulesDataSources, []); + + const externalPromRulesQueries = externalDataSources.map((ds) => + prometheusApi.endpoints.getGroups.useQuery({ + ruleSource: { uid: ds.uid }, + excludeAlerts: true, + groupLimit: 500, + }) + ); + + const isLoadingNamespaces = useMemo(() => { + return ( + isLoadingGrafanaPromRules || + isLoadingGrafanaRulerRules || + externalPromRulesQueries.some((query) => query.isLoading) + ); + }, [isLoadingGrafanaPromRules, isLoadingGrafanaRulerRules, externalPromRulesQueries]); + + const namespaceOptions = useMemo((): Array> => { + const grafanaFolders: Array> = []; + const externalNamespaces: Array> = []; + + // Grafana folders + grafanaPromRules.forEach((namespace) => { + grafanaFolders.push({ + label: namespace.name, + value: namespace.name, + description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), + }); + }); + + // External namespaces (dedupe by file) + externalPromRulesQueries.forEach((query) => { + const namespaces = new Set(); + query.currentData?.data?.groups?.forEach((group) => { + namespaces.add(group.file || 'default'); + }); + + namespaces.forEach((namespaceName) => { + if (namespaceName.includes('/') && (namespaceName.endsWith('.yml') || namespaceName.endsWith('.yaml'))) { + const filename = namespaceName.split('/').pop() || namespaceName; + const maxDescriptionLength = 100; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + externalNamespaces.push({ label: filename, value: namespaceName, description: truncatedDescription }); + } else { + const maxLength = 50; + const maxDescriptionLength = 100; + const truncatedName = + namespaceName.length > maxLength ? `${namespaceName.substring(0, maxLength)}...` : namespaceName; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + externalNamespaces.push({ label: truncatedName, value: namespaceName, description: truncatedDescription }); + } + }); + }); + + const collator = new Intl.Collator(); + grafanaFolders.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + externalNamespaces.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + + return [...grafanaFolders, ...externalNamespaces]; + }, [grafanaPromRules, externalPromRulesQueries]); + + const allGroupNames = useMemo(() => { + const groupSet = new Set(); + grafanaPromRules.forEach((namespace) => { + namespace.groups.forEach((group) => groupSet.add(group.name)); + }); + externalPromRulesQueries.forEach((query) => { + query.currentData?.data?.groups?.forEach((group) => { + groupSet.add(group.name); + }); + }); + return Array.from(groupSet).sort(); + }, [grafanaPromRules, externalPromRulesQueries]); + + const namespacePlaceholder = useMemo(() => { + if (isLoadingNamespaces) { + return t('common.loading', 'Loading...'); + } + if (namespaceOptions.length === 0) { + return t('alerting.rules-filter.no-namespaces', 'No folders available'); + } + return t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); + }, [isLoadingNamespaces, namespaceOptions.length]); + + const groupPlaceholder = useMemo(() => { + if (isLoadingNamespaces) { + return t('common.loading', 'Loading...'); + } + if (allGroupNames.length === 0) { + return t('alerting.rules-filter.no-groups', 'No groups available'); + } + return t('grafana.select-group', 'Select group'); + }, [isLoadingNamespaces, allGroupNames.length]); + + return { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder }; +} + +export function useLabelOptions(): { + labelOptions: Array>; + isLoadingGrafanaLabels: boolean; +} { + const { labels: grafanaLabels, isLoading: isLoadingGrafanaLabels } = + useGetLabelsFromDataSourceName(GRAFANA_RULES_SOURCE_NAME); + + const labelOptions = useMemo((): Array> => { + const infoOption: ComboboxOption = { + label: t('label-dropdown-info', "Can't find your label? Enter it manually"), + value: '__GRAFANA_LABEL_DROPDOWN_INFO__', + infoOption: true, + }; + + const selectableOptions = Array.from(grafanaLabels.entries()) + .flatMap(([key, values]) => + Array.from(values).map((value: string) => ({ label: `${key}=${value}`, value: `${key}=${value}` })) + ) + .sort((a, b) => new Intl.Collator().compare(a.label, b.label)); + + return [...selectableOptions, infoOption]; + }, [grafanaLabels]); + + return { labelOptions, isLoadingGrafanaLabels }; +} + +export function useAlertingDataSourceOptions(): Array> { + return useMemo(() => { + return getDataSourceSrv() + .getList({ alerting: true }) + .map((ds: DataSourceInstanceSettings) => ({ label: ds.name, value: ds.name })); + }, []); +} diff --git a/public/app/features/alerting/unified/components/rules/Filter/utils.ts b/public/app/features/alerting/unified/components/rules/Filter/utils.ts new file mode 100644 index 00000000000..049634634af --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/utils.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from 'react'; + +import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; +import { RulesFilter } from '../../../search/rulesSearchParser'; + +import { AdvancedFilters } from './RulesFilter.v2'; + +export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { + return { + freeFormWords: [], + ...values, + namespace: values.namespace || undefined, + groupName: values.groupName || undefined, + contactPoint: values.contactPoint || undefined, + ruleHealth: values.ruleHealth === '*' ? undefined : values.ruleHealth, + ruleState: values.ruleState === '*' ? undefined : values.ruleState, + ruleType: values.ruleType === '*' ? undefined : values.ruleType, + plugins: values.plugins === 'show' ? undefined : 'hide', + }; +} + +export const emptyAdvancedFilters: AdvancedFilters = { + namespace: null, + groupName: null, + ruleName: undefined, + ruleType: '*', + ruleState: '*', + dataSourceNames: [], + labels: [], + ruleHealth: '*', + dashboardUid: undefined, + plugins: 'show', + contactPoint: null, +}; + +export function searchQueryToDefaultValues(filterState: RulesFilter): AdvancedFilters { + return { + namespace: filterState.namespace ?? null, + groupName: filterState.groupName ?? null, + ruleName: filterState.ruleName, + ruleType: filterState.ruleType ?? '*', + ruleState: filterState.ruleState ?? '*', + dataSourceNames: filterState.dataSourceNames, + labels: filterState.labels, + ruleHealth: filterState.ruleHealth ?? '*', + dashboardUid: filterState.dashboardUid, + plugins: filterState.plugins ?? 'show', + contactPoint: filterState.contactPoint ?? null, + }; +} + +export function usePortalContainer(zIndex: number): HTMLElement | undefined { + const containerRef = useRef(null); + + useEffect(() => { + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + zIndex: String(zIndex), + }); + + document.body.appendChild(container); + containerRef.current = container; + + return () => { + container.remove(); + }; + }, [zIndex]); + + return containerRef.current || undefined; +} + +export function usePluginsFilterStatus() { + const { components } = useAlertingHomePageExtensions(); + return { pluginsFilterEnabled: components.length > 0 }; +} diff --git a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx b/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx new file mode 100644 index 00000000000..ecff8085f42 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx @@ -0,0 +1,366 @@ +import { render, screen } 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 { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import * as analytics from '../../Analytics'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; +import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; + +// Grant permission before importing the component since permission check happens at module level +grantUserPermissions([AccessControlAction.AlertingReceiversRead]); + +let mockFilterState: RulesFilterType = { + ruleName: '', + ruleState: undefined, + dataSourceNames: [], + freeFormWords: [], + labels: [], +}; +let mockSearchQuery = ''; +const mockUpdateFilters = jest.fn(); +const mockSetSearchQuery = jest.fn(); +const mockClearAll = jest.fn(); + +jest.mock('../../hooks/useFilteredRules', () => ({ + useRulesFilter: jest.fn(() => ({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + })), +})); + +import RulesFilter from './Filter/RulesFilter'; +import RulesFilterV2 from './Filter/RulesFilter.v2'; + +const useRulesFilterMock = useRulesFilter as jest.MockedFunction; + +setupMswServer(); + +jest.spyOn(analytics, 'trackFilterButtonClick'); +jest.spyOn(analytics, 'trackFilterButtonApplyClick'); +jest.spyOn(analytics, 'trackFilterButtonClearClick'); + +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('./MultipleDataSourcePicker', () => { + const original = jest.requireActual('./MultipleDataSourcePicker'); + return { + ...original, + MultipleDataSourcePicker: () => null, + }; +}); + +jest.mock('../../plugins/useAlertingHomePageExtensions', () => ({ + useAlertingHomePageExtensions: jest.fn(() => { + const { usePluginComponents } = jest.requireActual('@grafana/runtime'); + const { PluginExtensionPoints } = jest.requireActual('@grafana/data'); + return usePluginComponents({ + extensionPointId: PluginExtensionPoints.AlertingHomePage, + limitPerPlugin: 1, + }); + }), +})); + +setupPluginsExtensionsHook(); + +// Helper function to create mock plugin components +function createMockComponent(pluginId: string): ComponentTypeWithExtensionMeta<{}> { + function MockComponent() { + return
Test Plugin Component
; + } + + MockComponent.meta = { + id: `test-component-${pluginId}`, + pluginId, + title: 'Test Component', + description: 'Test plugin component', + type: PluginExtensionTypes.component, + } satisfies PluginExtensionComponentMeta; + + return MockComponent as ComponentTypeWithExtensionMeta<{}>; +} + +const ui = { + searchInput: byTestId('search-query-input'), + filterButton: byRole('button', { name: 'Filter' }), + applyButton: byTestId('filter-apply-button'), + clearButton: byTestId('filter-clear-button'), + ruleNameInput: byTestId('rule-name-input'), +}; + +beforeEach(() => { + locationService.replace({ search: '' }); + jest.clearAllMocks(); + + mockFilterState = { + ruleName: '', + ruleState: undefined, + dataSourceNames: [], + freeFormWords: [], + labels: [], + }; + mockSearchQuery = ''; + mockUpdateFilters.mockClear(); + mockSetSearchQuery.mockClear(); + mockClearAll.mockClear(); + mockSetSearchQuery.mockImplementation(() => {}); + + // Reset plugin components hook to default (no plugins) + setPluginComponentsHook(() => ({ + components: [], + isLoading: false, + })); +}); + +describe('RulesFilter Feature Flag', () => { + const originalFeatureToggle = config.featureToggles.alertingFilterV2; + + afterEach(() => { + config.featureToggles.alertingFilterV2 = originalFeatureToggle; + }); + + it('Should render RulesFilterV2 when alertingFilterV2 feature flag is enabled', async () => { + config.featureToggles.alertingFilterV2 = true; + + render(); + + // Wait for suspense to resolve and check that the V2 filter button is present + await screen.findByRole('button', { name: 'Filter' }); + expect(ui.filterButton.get()).toBeInTheDocument(); + expect(ui.searchInput.get()).toBeInTheDocument(); + }); + + it('Should render RulesFilterV1 when alertingFilterV2 feature flag is disabled', async () => { + config.featureToggles.alertingFilterV2 = false; + + render(); + + // Wait for suspense to resolve and check V1 structure + await screen.findByText('Search'); + + // V1 has search input but no V2-style filter button + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.query()).not.toBeInTheDocument(); + + // V1 has a help icon next to the search input + expect(screen.getByText('Search')).toBeInTheDocument(); + }); +}); + +describe('RulesFilterV2', () => { + it('Should render component without crashing', () => { + render(); + + expect(ui.searchInput.get()).toBeInTheDocument(); + expect(ui.filterButton.get()).toBeInTheDocument(); + }); + + it('Should allow typing in search input', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'test search'); + expect(ui.searchInput.get()).toHaveValue('test search'); + }); + + it('Should open filter popup when filter button is clicked', async () => { + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); + + it('Should close popup when clicking outside', async () => { + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + await user.click(document.body); + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument(); + }); + + it('Should clear filters and search field when clicking clear button', async () => { + const { user } = render(); + await user.type(ui.searchInput.get(), 'test'); + expect(ui.searchInput.get()).toHaveValue('test'); + await user.click(ui.filterButton.get()); + + await user.click(ui.clearButton.get()); + expect(ui.ruleNameInput.get()).toHaveValue(''); + + // Check that setSearchQuery was called with undefined to clear the search + expect(mockSetSearchQuery).toHaveBeenCalledWith(undefined); + }); + + it('Should populate search field with query string when filters are applied via rule name', async () => { + const { user } = 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 ?? ''; + }); + + await user.click(ui.applyButton.get()); + + // Check that setSearchQuery was called with the expected query + expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test'); + }); + + it('Should parse search query and call updateFilters when user types directly in search field', async () => { + const { user, rerender } = render(); + + // Type a search query directly into the search input + await user.type(ui.searchInput.get(), 'rule:test state:firing'); + + // Trigger the onBlur handler by clicking elsewhere + await user.click(document.body); + + // Verify updateFilters was called with the parsed filter + expect(mockUpdateFilters).toHaveBeenCalledWith({ + dataSourceNames: [], + freeFormWords: [], + labels: [], + ruleName: 'test', + ruleState: 'firing', + }); + + // Simulate the filter state update by updating our mock + mockFilterState = { + dataSourceNames: [], + freeFormWords: [], + labels: [], + ruleName: 'test', + ruleState: PromAlertingRuleState.Firing, + }; + + // Update the mock to return the new state + useRulesFilterMock.mockReturnValue({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + }); + + // Force a re-render to pick up the new filter state + rerender(); + + // Open the filter popup + await user.click(ui.filterButton.get()); + + expect(ui.ruleNameInput.get()).toHaveValue('test'); + + const firingRadio = screen.getByRole('radio', { name: 'Firing' }); + expect(firingRadio).toBeChecked(); + }); + + it('Should handle free-form rule name search in query string', async () => { + const { user } = render(); + + // Type a free-form search (no filter prefix) + await user.type(ui.searchInput.get(), 'test'); + + // Trigger the search by pressing Enter + await user.keyboard('{Enter}'); + + // The search input should retain the value + expect(ui.searchInput.get()).toHaveValue('test'); + }); + + describe('Conditional Fields', () => { + it('Should show contact point field when user has proper permissions', async () => { + // Permission is already mocked to true at module level + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByText('Contact point')).toBeInTheDocument(); + }); + + it('Should show plugin filter when plugins are enabled', async () => { + // Mock plugin components to simulate plugins available + setPluginComponentsHook(() => ({ + components: [createMockComponent('test-plugin')], + isLoading: false, + })); + + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.getByText('Plugin rules')).toBeInTheDocument(); + }); + + it('Should hide plugin filter when no plugins are available', async () => { + // Mock plugin components to return no components + setPluginComponentsHook(() => ({ + components: [], + isLoading: false, + })); + + const { user } = render(); + await user.click(ui.filterButton.get()); + expect(screen.queryByText('Plugin rules')).not.toBeInTheDocument(); + }); + }); + + describe('Analytics Tracking', () => { + it('Should track filter button clicks when opening popup', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + }); + + it('Should track clear button clicks', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + await user.click(ui.clearButton.get()); + + expect(analytics.trackFilterButtonClearClick).toHaveBeenCalledTimes(1); + }); + + it('Should track apply button clicks with filter values', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + await user.click(ui.applyButton.get()); + + expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); + }); + + it('Should not track filter button click when filter button is clicked to close popup', async () => { + const { user } = render(); + + await user.click(ui.filterButton.get()); + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + + await user.click(document.body); + + jest.clearAllMocks(); + + await user.click(ui.filterButton.get()); + expect(analytics.trackFilterButtonClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 544b0db9975..fb78380b180 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -80,7 +80,11 @@ export function useRulesFilter() { } }, [queryParams, updateFilters, filterState, updateQueryParams]); - return { filterState, hasActiveFilters, activeFilters, searchQuery, setSearchQuery, updateFilters }; + const clearAll = useCallback(() => { + updateQueryParams({ search: undefined }); + }, [updateQueryParams]); + + return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters, clearAll, activeFilters }; } export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c38a2f7a770..4a294622625 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1244,20 +1244,6 @@ "copy-code": "Copy code", "download": "Download" }, - "filter-options": { - "label": { - "alert-rule": "Alert rule", - "all": "All", - "error": "Error", - "firing": "Firing", - "no-data": "No data", - "normal": "Normal", - "ok": "OK", - "pending": "Pending", - "recording-rule": "Recording rule", - "recovering": "Recovering" - } - }, "filter-view-results": { "aria-label-filteredrulelist": "filtered-rule-list" }, @@ -2588,12 +2574,28 @@ "delete-rule": { "success": "Rule successfully deleted" }, + "health": { + "error": "Error", + "no-data": "No data", + "ok": "OK" + }, "pause-rule": { "success": "Rule evaluation paused" }, "resume-rule": { "success": "Rule evaluation resumed" }, + "state": { + "firing": "Firing", + "normal": "Normal", + "pending": "Pending", + "recovering": "Recovering", + "unknown": "Unknown" + }, + "type": { + "alert": "Alert rule", + "recording": "Recording rule" + }, "update-rule": { "success": "Rule updated successfully" } @@ -2601,20 +2603,29 @@ "rules-filter": { "clear-filters": "Clear filters", "configured-alert-rules": "Data sources containing configured alert rules are Mimir or Loki data sources where alert rules are stored and evaluated in the data source itself.", + "contact-point-tooltip": "Filters alert rules which route directly to the selected contact point. Alert rules routed to notification policies will not be displayed.", + "contact-point-tooltip-title": "Contact point filter help", "dashboard": "Dashboard", "data-source-picker-inline-help-title-search-by-data-sources-help": "Search by data sources help", "filter-options": { - "aria-label-show-filters": "Show filters", - "label-custom-filter": "Custom filter", - "label-saved-searches": "Saved searches" + "aria-label": "Filter options", + "aria-label-show-filters": "Filter", + "placeholder-namespace": "Select namespace", + "placeholder-search-input": "Search by name or enter filter query..." }, + "grafana-folder": "Grafana folder", "health": "Health", "label": { "hide": "Hide", "show": "Show" }, "manage-alerts": "In these data sources, you can select Manage alerts via Alerting UI to be able to manage these alert rules in the Grafana UI as well as in the data source where they were configured.", + "no-groups": "No groups available", + "no-namespaces": "No folders available", "placeholder-all-data-sources": "All data sources", + "placeholder-contact-point": "Select contact point", + "placeholder-data-sources": "Select data sources", + "placeholder-labels": "Select labels", "plugin-rules": "Plugin rules", "rule-type": "Rule type", "rulesSearchInput-placeholder-search": "Search", @@ -2629,9 +2640,6 @@ "text-federated": "Federated", "text-provisioned": "Provisioned" }, - "saved-searches": { - "text-default": "Default" - }, "search": { "property": { "data-source": "Data source", @@ -2639,11 +2647,10 @@ "labels": "Labels", "namespace": "Folder / Namespace", "rule-health": "Health", - "rule-name": "Alerting rule name", + "rule-name": "Rule name", "rule-type": "Type", "state": "State" - }, - "save-query": "Save current search" + } }, "search-field-input": { "clear": "Clear", @@ -4096,10 +4103,10 @@ } }, "common": { + "all": "All", "apply": "Apply", "cancel": "Cancel", "clear": "Clear", - "close": "Close", "collapse": "Collapse", "edit": "Edit", "help": "Help", @@ -7874,7 +7881,8 @@ "edit-pane": { "go-back": "Go back" } - } + }, + "select-group": "Select group" }, "grafana-data": { "valueFormats": { @@ -9063,6 +9071,7 @@ "sign-up": "Sign up" } }, + "label-dropdown-info": "Can't find your label? Enter it manually", "layers": { "layer-drag-drop-list": { "draggable-aria-label": "Drag and drop to reorder",