diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e3e822abdc4..d98da01623c 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1372,11 +1372,6 @@ "count": 2 } }, - "public/app/features/alerting/unified/components/AlertLabelDropdown.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -1593,11 +1588,6 @@ "count": 3 } }, - "public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx": { - "no-restricted-syntax": { - "count": 4 - } - }, "public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx": { "no-restricted-syntax": { "count": 1 diff --git a/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx index ff70fce4203..d8af733ef77 100644 --- a/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx +++ b/public/app/features/alerting/unified/components/AlertLabelDropdown.tsx @@ -5,10 +5,12 @@ import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; import { Combobox, ComboboxOption, Field, useStyles2 } from '@grafana/ui'; +export type AsyncOptionsLoader = (inputValue: string) => Promise>>; + export interface AlertLabelDropdownProps { onChange: (newValue: SelectableValue) => void; onOpenMenu?: () => void; - options: ComboboxOption[]; + options: ComboboxOption[] | AsyncOptionsLoader; defaultValue?: SelectableValue; type: 'key' | 'value'; isLoading?: boolean; @@ -38,7 +40,7 @@ const AlertLabelDropdown: FC = forwardRef - + placeholder={t('alerting.alert-label-dropdown.placeholder-select', 'Choose {{type}}', { type })} width={25} diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index b1571a2a6a5..30da1c9e41d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { FC, useCallback, useMemo } from 'react'; import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form'; import { AlertLabels } from '@grafana/alerting/unstable'; @@ -13,7 +13,7 @@ import { SupportedPlugin } from '../../../types/pluginBridges'; import { KBObjectArray, RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { isPrivateLabelKey } from '../../../utils/labels'; import { isRecordingRuleByType } from '../../../utils/rules'; -import AlertLabelDropdown from '../../AlertLabelDropdown'; +import AlertLabelDropdown, { AsyncOptionsLoader } from '../../AlertLabelDropdown'; import { NeedHelpInfo } from '../NeedHelpInfo'; import { useGetLabelsFromDataSourceName } from '../useAlertRuleSuggestions'; @@ -117,8 +117,7 @@ export function useCombinedLabels( dataSourceName: string, labelsPluginInstalled: boolean, loadingLabelsPlugin: boolean, - labelsInSubform: Array<{ key: string; value: string }>, - selectedKey: string + labelsInSubform: Array<{ key: string; value: string }> ) { // ------- Get labels keys and their values from existing alerts const { labels: labelsByKeyFromExisingAlerts, isLoading } = useGetLabelsFromDataSourceName(dataSourceName); @@ -126,18 +125,19 @@ export function useCombinedLabels( const { loading: isLoadingLabels, labelsOpsKeys = [] } = useGetOpsLabelsKeys( !labelsPluginInstalled || loadingLabelsPlugin ); - //------ Convert the labelsOpsKeys to the same format as the labelsByKeyFromExisingAlerts - const labelsByKeyOps = useMemo(() => { - return labelsOpsKeys.reduce((acc: Record>, label) => { - acc[label.name] = new Set(); - return acc; - }, {}); + + // Lazy query for fetching label values on demand + const [fetchLabelValues] = labelsApi.endpoints.getLabelValues.useLazyQuery(); + + //------ Convert the labelsOpsKeys to a Set for quick lookup + const opsLabelKeysSet = useMemo(() => { + return new Set(labelsOpsKeys.map((label) => label.name)); }, [labelsOpsKeys]); //------- Convert the keys from the ops labels to options for the dropdown const keysFromGopsLabels = useMemo(() => { - return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform); - }, [labelsByKeyOps, labelsInSubform]); + return mapLabelsToOptions(Array.from(opsLabelKeysSet).filter(isKeyAllowed), labelsInSubform); + }, [opsLabelKeysSet, labelsInSubform]); //------- Convert the keys from the existing alerts to options for the dropdown const keysFromExistingAlerts = useMemo(() => { @@ -158,70 +158,47 @@ export function useCombinedLabels( }, ]; - const selectedKeyIsFromAlerts = labelsByKeyFromExisingAlerts.has(selectedKey); - const selectedKeyIsFromOps = labelsByKeyOps[selectedKey] !== undefined && labelsByKeyOps[selectedKey]?.size > 0; - const selectedKeyDoesNotExist = !selectedKeyIsFromAlerts && !selectedKeyIsFromOps; + // Create an async options loader for a specific key + // This is called by Combobox when the dropdown menu opens + const createAsyncValuesLoader = useCallback( + (key: string): AsyncOptionsLoader => { + return async (_inputValue: string): Promise>> => { + if (!isKeyAllowed(key) || !key) { + return []; + } - const valuesAlreadyFetched = !selectedKeyIsFromAlerts && labelsByKeyOps[selectedKey]?.size > 0; + // Collect values from existing alerts first + const valuesFromAlerts = labelsByKeyFromExisingAlerts.get(key); + const existingValues = valuesFromAlerts ? Array.from(valuesFromAlerts) : []; - // Only fetch the values for the selected key if it is from ops and the values are not already fetched (the selected key is not in the labelsByKeyOps object) - const { - currentData: valuesData, - isLoading: isLoadingValues = false, - error, - } = labelsApi.endpoints.getLabelValues.useQuery( - { key: selectedKey }, - { - skip: - !labelsPluginInstalled || - !selectedKey || - selectedKeyIsFromAlerts || - valuesAlreadyFetched || - selectedKeyDoesNotExist, - } - ); + // Collect values from ops labels (if plugin is installed) + let opsValues: string[] = []; + if (labelsPluginInstalled && opsLabelKeysSet.has(key)) { + try { + // RTK Query handles caching automatically + const result = await fetchLabelValues({ key }, true).unwrap(); + if (result?.values?.length) { + opsValues = result.values.map((value) => value.name); + } + } catch (error) { + console.error('Failed to fetch label values for key:', key, error); + } + } - // these are the values for the selected key in case it is from ops - const valuesFromSelectedGopsKey = useMemo(() => { - // if it is from alerts, we need to fetch the values from the existing alerts - if (selectedKeyIsFromAlerts) { - return []; - } - // in case of a label from ops, we need to fetch the values from the plugin - // fetch values from ops only if there is no value for the key - const valuesForSelectedKey = labelsByKeyOps[selectedKey]; - const valuesAlreadyFetched = valuesForSelectedKey?.size > 0; - if (valuesAlreadyFetched) { - return mapLabelsToOptions(valuesForSelectedKey); - } - if (!isLoadingValues && valuesData?.values?.length && !error) { - const values = valuesData?.values.map((value) => value.name); - labelsByKeyOps[selectedKey] = new Set(values); - return mapLabelsToOptions(values); - } - return []; - }, [selectedKeyIsFromAlerts, labelsByKeyOps, selectedKey, isLoadingValues, valuesData, error]); + // Combine: existing values first, then unique ops values (Set preserves first occurrence) + const combinedValues = [...new Set([...existingValues, ...opsValues])]; - const getValuesForLabel = useCallback( - (key: string) => { - if (!isKeyAllowed(key)) { - return []; - } - - // values from existing alerts will take precedence over values from ops - if (selectedKeyIsFromAlerts || !labelsPluginInstalled) { - return mapLabelsToOptions(labelsByKeyFromExisingAlerts.get(key)); - } - return valuesFromSelectedGopsKey; + return mapLabelsToOptions(combinedValues); + }; }, - [labelsByKeyFromExisingAlerts, labelsPluginInstalled, valuesFromSelectedGopsKey, selectedKeyIsFromAlerts] + [labelsByKeyFromExisingAlerts, labelsPluginInstalled, opsLabelKeysSet, fetchLabelValues] ); return { loading: isLoading || isLoadingLabels, keysFromExistingAlerts, groupedOptions, - getValuesForLabel, + createAsyncValuesLoader, }; } @@ -248,30 +225,30 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP append({ key: '', value: '' }); }, [append]); - const [selectedKey, setSelectedKey] = useState(''); // check if the labels plugin is installed const { installed: labelsPluginInstalled = false, loading: loadingLabelsPlugin } = usePluginBridge( SupportedPlugin.Labels ); - const { loading, keysFromExistingAlerts, groupedOptions, getValuesForLabel } = useCombinedLabels( + const { loading, keysFromExistingAlerts, groupedOptions, createAsyncValuesLoader } = useCombinedLabels( dataSourceName, labelsPluginInstalled, loadingLabelsPlugin, - labelsInSubform, - selectedKey + labelsInSubform ); return ( {fields.map((field, index) => { - // Get the values for this specific row's key directly without memoization + // Create an async loader for this specific row's key + // This will be called by Combobox when the dropdown opens const currentKey = labelsInSubform[index]?.key || ''; - const valuesForCurrentKey = getValuesForLabel(currentKey); + const asyncValuesLoader = createAsyncValuesLoader(currentKey); return ( -
+
{ if (newValue) { onChange(newValue.value || newValue.label || ''); - setSelectedKey(newValue.value); } }} type="key" @@ -306,6 +282,7 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP = { if (newValue) { onChange(newValue.value || newValue.label || ''); } }} - onOpenMenu={() => { - setSelectedKey(labelsInSubform[index].key); - }} type="value" /> ); @@ -368,6 +342,7 @@ export const LabelsWithoutSuggestions: FC = () => {
{ = }>) => { + const methods = useForm({ defaultValues: { labelsInSubform: labels } }); + return {children}; +}; + +const grafanaRule = getGrafanaRule(undefined, { + uid: 'test-rule-uid', + title: 'test-alert', + namespace_uid: 'folderUID1', + data: [ + { + refId: 'A', + datasourceUid: 'uid1', + queryType: 'alerting', + relativeTimeRange: { from: 1000, to: 2000 }, + model: { + refId: 'A', + expression: 'vector(1)', + queryType: 'alerting', + datasource: { uid: 'uid1', type: 'prometheus' }, + }, + }, + ], +}); + +// Use the standard MSW server setup which includes all plugin handlers +const server = setupMswServer(); + +describe('LabelsField with ops labels', () => { + beforeEach(() => { + // Mock the ruler rules API + mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { + [grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }], + }); + }); + + afterEach(() => { + server.resetHandlers(); + clearPluginSettingsCache(); + }); + + async function renderLabelsWithOpsLabels(labels = existingOpsLabels) { + const view = render( + + + + ); + + // Wait for the dropdowns to be rendered + await waitFor(() => { + expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(labels.length); + }); + + return view; + } + + it('should display existing ops labels correctly', async () => { + await renderLabelsWithOpsLabels(); + + // Verify the keys are displayed + expect(screen.getByTestId('labelsInSubform-key-0').querySelector('input')).toHaveValue(existingOpsLabels[0].key); + expect(screen.getByTestId('labelsInSubform-key-1').querySelector('input')).toHaveValue(existingOpsLabels[1].key); + + // Verify the values are displayed + expect(screen.getByTestId('labelsInSubform-value-0').querySelector('input')).toHaveValue( + existingOpsLabels[0].value + ); + expect(screen.getByTestId('labelsInSubform-value-1').querySelector('input')).toHaveValue( + existingOpsLabels[1].value + ); + }); + + it('should render value dropdowns for each label', async () => { + await renderLabelsWithOpsLabels(); + + // Verify we have value pickers for each label + expect(screen.getAllByTestId('alertlabel-value-picker')).toHaveLength(2); + }); + + it('should allow deleting a label', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(2); + + await user.click(screen.getByTestId('delete-label-1')); + + expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1); + expect(screen.getByTestId('labelsInSubform-key-0').querySelector('input')).toHaveValue(existingOpsLabels[0].key); + }); + + it('should allow adding a new label', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + await waitFor(() => expect(screen.getByText('Add more')).toBeVisible()); + await user.click(screen.getByText('Add more')); + + expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(3); + expect(screen.getByTestId('labelsInSubform-key-2').querySelector('input')).toHaveValue(''); + }); + + it('should allow typing custom values in dropdowns', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + // Add a new label + await waitFor(() => expect(screen.getByText('Add more')).toBeVisible()); + await user.click(screen.getByText('Add more')); + + // Type a custom key and value + const newKeyInput = screen.getByTestId('labelsInSubform-key-2').querySelector('input'); + const newValueInput = screen.getByTestId('labelsInSubform-value-2').querySelector('input'); + + await user.type(newKeyInput!, 'customKey{enter}'); + await user.type(newValueInput!, 'customValue{enter}'); + + await waitFor(() => { + expect(screen.getByTestId('labelsInSubform-key-2').querySelector('input')).toHaveValue('customKey'); + }); + expect(screen.getByTestId('labelsInSubform-value-2').querySelector('input')).toHaveValue('customValue'); + }); + + // When editing an existing alert with labels, the value dropdown should open and be interactive + it('should allow opening and interacting with existing label value dropdown', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + // Click on the first label's value dropdown (sentMail) to open it + const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0')); + const combobox = firstValueDropdown.getByRole('combobox'); + + // Verify initial value is set + expect(combobox).toHaveValue(existingOpsLabels[0].value); + + // Open the dropdown + await user.click(combobox); + + // Verify dropdown is open (not showing "No options found" state) + expect(combobox).toHaveAttribute('aria-expanded', 'true'); + + // Close and reopen to verify it remains interactive + await user.keyboard('{Escape}'); + expect(combobox).toHaveAttribute('aria-expanded', 'false'); + + await user.click(combobox); + expect(combobox).toHaveAttribute('aria-expanded', 'true'); + }); + + // Test that value dropdowns can be opened and interacted with for different label keys + // Note: Dropdown content cannot be verified via text due to Combobox virtualization in JSDOM + it('should allow opening value dropdowns for different label keys', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + // Open the first label's value dropdown (sentMail) + const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0')); + const firstCombobox = firstValueDropdown.getByRole('combobox'); + await user.click(firstCombobox); + + // Verify dropdown is open + expect(firstCombobox).toHaveAttribute('aria-expanded', 'true'); + + // Close and open second dropdown + await user.keyboard('{Escape}'); + + // Open the second label's value dropdown (stage) + const secondValueDropdown = within(screen.getByTestId('labelsInSubform-value-1')); + const secondCombobox = secondValueDropdown.getByRole('combobox'); + await user.click(secondCombobox); + + // Verify second dropdown is open + expect(secondCombobox).toHaveAttribute('aria-expanded', 'true'); + }); + + // Test that after deleting and re-adding a label, the value dropdown can be opened + it('should allow opening value dropdown after deleting and re-adding a label', async () => { + const { user } = await renderLabelsWithOpsLabels(); + + // Delete the second label (stage) + await user.click(screen.getByTestId('delete-label-1')); + expect(screen.getAllByTestId('alertlabel-key-picker')).toHaveLength(1); + + // Add a new label + await waitFor(() => expect(screen.getByText('Add more')).toBeVisible()); + await user.click(screen.getByText('Add more')); + + // Set the new label key to 'team' + const newKeyDropdown = within(screen.getByTestId('labelsInSubform-key-1')); + await user.type(newKeyDropdown.getByRole('combobox'), 'team{enter}'); + + // Verify the key was set + await waitFor(() => { + expect(screen.getByTestId('labelsInSubform-key-1').querySelector('input')).toHaveValue('team'); + }); + + // Open the new label's value dropdown + const newValueDropdown = within(screen.getByTestId('labelsInSubform-value-1')); + const combobox = newValueDropdown.getByRole('combobox'); + await user.click(combobox); + + // Verify dropdown is open + expect(combobox).toHaveAttribute('aria-expanded', 'true'); + }); + + // Test that opening the value dropdown requests values for the CORRECT label key + // This verifies the async loader is called with the right key + it('should request correct label values when opening value dropdown', async () => { + const requestedKeys: string[] = []; + + // Add a spy handler that tracks which keys are requested + server.use(getLabelValuesHandler(defaultLabelValues, (key) => requestedKeys.push(key))); + + const { user } = await renderLabelsWithOpsLabels(); + + // Open the first label's value dropdown (sentMail) + const firstValueDropdown = within(screen.getByTestId('labelsInSubform-value-0')); + await user.click(firstValueDropdown.getByRole('combobox')); + + // Wait for the API call to be made + await waitFor(() => { + expect(requestedKeys).toContain('sentMail'); + }); + + // Close dropdown + await user.keyboard('{Escape}'); + + // Clear the tracked keys + requestedKeys.length = 0; + + // Open the second label's value dropdown (stage) + const secondValueDropdown = within(screen.getByTestId('labelsInSubform-value-1')); + await user.click(secondValueDropdown.getByRole('combobox')); + + // Wait for the API call - should request 'stage', NOT 'sentMail' + await waitFor(() => { + expect(requestedKeys).toContain('stage'); + }); + + // Verify we didn't request the wrong key (the bug from escalation #19378) + expect(requestedKeys).not.toContain('sentMail'); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/useCombinedLabels.test.ts b/public/app/features/alerting/unified/components/rule-editor/labels/useCombinedLabels.test.ts new file mode 100644 index 00000000000..be697bd5733 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/labels/useCombinedLabels.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for the createAsyncValuesLoader function in useCombinedLabels hook. + * + * These tests verify that: + * 1. Values from existing alerts are shown first + * 2. Values from ops labels are shown after existing values + * 3. Duplicate values between existing and ops are excluded from ops + * 4. The order is: existing values first, then unique ops values + */ + +describe('createAsyncValuesLoader logic', () => { + // Simulate the data structures used in useCombinedLabels + const labelsByKeyFromExistingAlerts = new Map>([ + ['severity', new Set(['warning', 'error', 'critical'])], + ['team', new Set(['frontend', 'backend', 'platform'])], + ['environment', new Set(['production', 'staging'])], + ]); + + // Simulate ops labels (from grafana-labels-app plugin) + const opsLabelValues: Record = { + severity: ['info', 'warning', 'critical', 'fatal'], // 'warning' and 'critical' overlap with existing + team: ['frontend', 'sre', 'devops'], // 'frontend' overlaps with existing + environment: ['production', 'staging', 'development', 'testing'], // 'production' and 'staging' overlap + cluster: ['us-east-1', 'us-west-2', 'eu-central-1'], // ops-only key + }; + + const opsLabelKeys = new Set(['severity', 'team', 'environment', 'cluster']); + + const mapLabelsToOptions = (items: string[]) => { + return items.map((item) => ({ label: item, value: item })); + }; + + // This simulates the current implementation of createAsyncValuesLoader + const getValuesForLabel = (key: string, labelsPluginInstalled: boolean): Array<{ label: string; value: string }> => { + if (!key) { + return []; + } + + // Collect values from existing alerts first + const valuesFromAlerts = labelsByKeyFromExistingAlerts.get(key); + const existingValues = valuesFromAlerts ? Array.from(valuesFromAlerts) : []; + + // Collect values from ops labels (if plugin is installed) + let opsValues: string[] = []; + if (labelsPluginInstalled && opsLabelKeys.has(key)) { + opsValues = opsLabelValues[key] || []; + } + + // Combine: existing values first, then unique ops values (Set preserves first occurrence) + const combinedValues = [...new Set([...existingValues, ...opsValues])]; + + return mapLabelsToOptions(combinedValues); + }; + + describe('when labels plugin is installed', () => { + it('should combine existing and ops values with existing first', () => { + const values = getValuesForLabel('severity', true); + + // Existing: warning, error, critical + // Ops: info, warning, critical, fatal (warning and critical are duplicates) + // Expected: warning, error, critical, info, fatal + expect(values).toHaveLength(5); + expect(values.map((v) => v.value)).toEqual(['warning', 'error', 'critical', 'info', 'fatal']); + }); + + it('should exclude duplicate ops values that exist in existing alerts', () => { + const values = getValuesForLabel('environment', true); + + // Existing: production, staging + // Ops: production, staging, development, testing (production and staging are duplicates) + // Expected: production, staging, development, testing + expect(values).toHaveLength(4); + expect(values.map((v) => v.value)).toEqual(['production', 'staging', 'development', 'testing']); + }); + + it('should return only ops values for ops-only keys', () => { + const values = getValuesForLabel('cluster', true); + + // No existing alerts for 'cluster', only ops values + expect(values).toHaveLength(3); + expect(values.map((v) => v.value)).toEqual(['us-east-1', 'us-west-2', 'eu-central-1']); + }); + + it('should return only existing values for keys not in ops', () => { + // Add a key that exists in alerts but not in ops + labelsByKeyFromExistingAlerts.set('custom', new Set(['value1', 'value2'])); + + const values = getValuesForLabel('custom', true); + + expect(values).toHaveLength(2); + expect(values.map((v) => v.value)).toEqual(['value1', 'value2']); + + // Cleanup + labelsByKeyFromExistingAlerts.delete('custom'); + }); + }); + + describe('when labels plugin is NOT installed', () => { + it('should return only existing alert values', () => { + const values = getValuesForLabel('severity', false); + + // Only existing values, no ops values + expect(values).toHaveLength(3); + expect(values.map((v) => v.value)).toEqual(['warning', 'error', 'critical']); + }); + + it('should return empty array for ops-only keys', () => { + const values = getValuesForLabel('cluster', false); + + // 'cluster' only exists in ops, not in existing alerts + expect(values).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('should return empty array for empty key', () => { + const values = getValuesForLabel('', true); + expect(values).toHaveLength(0); + }); + + it('should return empty array for unknown keys', () => { + const values = getValuesForLabel('unknown-key', true); + expect(values).toHaveLength(0); + }); + + it('should preserve order: existing values first, then unique ops values', () => { + const values = getValuesForLabel('team', true); + + // Existing: frontend, backend, platform + // Ops: frontend, sre, devops (frontend is duplicate) + // Expected order: frontend, backend, platform, sre, devops + const valueStrings = values.map((v) => v.value); + + // Check that existing values come before ops values + expect(valueStrings.indexOf('frontend')).toBeLessThan(valueStrings.indexOf('sre')); + expect(valueStrings.indexOf('backend')).toBeLessThan(valueStrings.indexOf('sre')); + expect(valueStrings.indexOf('platform')).toBeLessThan(valueStrings.indexOf('devops')); + }); + }); +}); diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers.ts index 53345209094..17f88c687cc 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins/all-plugin-handlers.ts @@ -1,11 +1,12 @@ /** * Re-exports all plugin proxy handlers */ +import labelsHandlers from './grafana-labels-app'; import onCallHandlers from './grafana-oncall'; /** * Array of all plugin handlers that are required across Alerting tests */ -const allPluginProxyHandlers = [...onCallHandlers]; +const allPluginProxyHandlers = [...onCallHandlers, ...labelsHandlers]; export default allPluginProxyHandlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins/grafana-labels-app.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins/grafana-labels-app.ts new file mode 100644 index 00000000000..b4e8d225c53 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins/grafana-labels-app.ts @@ -0,0 +1,79 @@ +import { HttpResponse, http } from 'msw'; + +import { LabelItem, LabelKeyAndValues } from 'app/features/alerting/unified/api/labelsApi'; +import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; + +const BASE_URL = `/api/plugins/${SupportedPlugin.Labels}/resources`; + +// Default mock data for ops labels +export const defaultLabelKeys: LabelItem[] = [ + { id: '1', name: 'sentMail', prescribed: false }, + { id: '2', name: 'stage', prescribed: false }, + { id: '3', name: 'team', prescribed: false }, +]; + +export const defaultLabelValues: Record = { + sentMail: [ + { id: '1', name: 'true', prescribed: false }, + { id: '2', name: 'false', prescribed: false }, + ], + stage: [ + { id: '1', name: 'production', prescribed: false }, + { id: '2', name: 'staging', prescribed: false }, + { id: '3', name: 'development', prescribed: false }, + ], + team: [ + { id: '1', name: 'frontend', prescribed: false }, + { id: '2', name: 'backend', prescribed: false }, + { id: '3', name: 'platform', prescribed: false }, + ], +}; + +/** + * Helper to generate mock ops labels in the form format (key-value pairs). + * @param keys - Array of label key names to include (defaults to first two: sentMail, stage) + * @param labelValues - Optional custom label values map + * @returns Array of { key, value } objects for use in form tests + */ +export function getMockOpsLabels( + keys: string[] = [defaultLabelKeys[0].name, defaultLabelKeys[1].name], + labelValues: Record = defaultLabelValues +): Array<{ key: string; value: string }> { + return keys.map((key) => ({ + key, + value: labelValues[key]?.[0]?.name ?? '', + })); +} + +/** + * Handler for GET /api/plugins/grafana-labels-app/resources/v1/labels/keys + * Returns all available label keys + */ +export const getLabelsKeysHandler = (labelKeys: LabelItem[] = defaultLabelKeys) => + http.get(`${BASE_URL}/v1/labels/keys`, () => { + return HttpResponse.json(labelKeys); + }); + +/** + * Handler for GET /api/plugins/grafana-labels-app/resources/v1/labels/name/:key + * Returns values for a specific label key. + * @param labelValues - Custom label values map (defaults to defaultLabelValues) + * @param onKeyRequested - Optional callback to spy on which keys are requested (useful for testing) + */ +export const getLabelValuesHandler = ( + labelValues: Record = defaultLabelValues, + onKeyRequested?: (key: string) => void +) => + http.get<{ key: string }>(`${BASE_URL}/v1/labels/name/:key`, ({ params }) => { + const key = params.key; + onKeyRequested?.(key); + const values = labelValues[key] || []; + const response: LabelKeyAndValues = { + labelKey: { id: '1', name: key, prescribed: false }, + values, + }; + return HttpResponse.json(response); + }); + +const handlers = [getLabelsKeysHandler(), getLabelValuesHandler()]; +export default handlers; diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index 68c3b81944a..0917ce1a7c3 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -134,6 +134,29 @@ export const pluginMeta = { module: 'public/plugins/grafana-asserts-app/module.js', baseUrl: 'public/plugins/grafana-asserts-app', } satisfies PluginMeta, + [SupportedPlugin.Labels]: { + id: SupportedPlugin.Labels, + name: 'Labels', + type: PluginType.app, + enabled: true, + info: { + author: { + name: 'Grafana Labs', + url: '', + }, + description: 'Labels management for alerting', + links: [], + logos: { + small: 'public/plugins/grafana-labels-app/img/logo.svg', + large: 'public/plugins/grafana-labels-app/img/logo.svg', + }, + screenshots: [], + version: 'local-dev', + updated: '2024-04-09', + }, + module: 'public/plugins/grafana-labels-app/module.js', + baseUrl: 'public/plugins/grafana-labels-app', + } satisfies PluginMeta, }; export const plugins: PluginMeta[] = [ @@ -141,6 +164,7 @@ export const plugins: PluginMeta[] = [ pluginMeta[SupportedPlugin.Incident], pluginMeta[SupportedPlugin.OnCall], pluginMeta['grafana-asserts-app'], + pluginMeta[SupportedPlugin.Labels], ]; export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfig {