Alerting: Fix label values not being shown in the label drop down (#114642)
* fix label values not being shown in the label drop down * update fetching gops label values * improve tests * add test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Array<ComboboxOption<string>>>;
|
||||
|
||||
export interface AlertLabelDropdownProps {
|
||||
onChange: (newValue: SelectableValue<string>) => void;
|
||||
onOpenMenu?: () => void;
|
||||
options: ComboboxOption[];
|
||||
options: ComboboxOption[] | AsyncOptionsLoader;
|
||||
defaultValue?: SelectableValue;
|
||||
type: 'key' | 'value';
|
||||
isLoading?: boolean;
|
||||
@@ -38,7 +40,7 @@ const AlertLabelDropdown: FC<AlertLabelDropdownProps> = forwardRef<HTMLDivElemen
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<Field disabled={false} data-testid={`alertlabel-${type}-picker`} className={styles.resetMargin}>
|
||||
<Field noMargin disabled={false} data-testid={`alertlabel-${type}-picker`} className={styles.resetMargin}>
|
||||
<Combobox<string>
|
||||
placeholder={t('alerting.alert-label-dropdown.placeholder-select', 'Choose {{type}}', { type })}
|
||||
width={25}
|
||||
|
||||
+53
-77
@@ -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<string, Set<string>>, 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<Array<ComboboxOption<string>>> => {
|
||||
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 (
|
||||
<Stack direction="column" gap={2} alignItems="flex-start">
|
||||
{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 (
|
||||
<div key={field.id} className={cx(styles.flexRow, styles.centerAlignRow)} id="hola">
|
||||
<div key={field.id} className={cx(styles.flexRow, styles.centerAlignRow)}>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={Boolean(errors.labelsInSubform?.[index]?.key?.message)}
|
||||
error={errors.labelsInSubform?.[index]?.key?.message}
|
||||
@@ -295,7 +272,6 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
onChange={(newValue: SelectableValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value || newValue.label || '');
|
||||
setSelectedKey(newValue.value);
|
||||
}
|
||||
}}
|
||||
type="key"
|
||||
@@ -306,6 +282,7 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={Boolean(errors.labelsInSubform?.[index]?.value?.message)}
|
||||
error={errors.labelsInSubform?.[index]?.value?.message}
|
||||
@@ -320,16 +297,13 @@ export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsP
|
||||
<AlertLabelDropdown
|
||||
{...rest}
|
||||
defaultValue={value ? { label: value, value: value } : undefined}
|
||||
options={valuesForCurrentKey}
|
||||
options={asyncValuesLoader}
|
||||
isLoading={loading}
|
||||
onChange={(newValue: SelectableValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value || newValue.label || '');
|
||||
}
|
||||
}}
|
||||
onOpenMenu={() => {
|
||||
setSelectedKey(labelsInSubform[index].key);
|
||||
}}
|
||||
type="value"
|
||||
/>
|
||||
);
|
||||
@@ -368,6 +342,7 @@ export const LabelsWithoutSuggestions: FC = () => {
|
||||
<div key={field.id}>
|
||||
<div className={cx(styles.flexRow, styles.centerAlignRow)} data-testid="alertlabel-input-wrapper">
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.key?.message}
|
||||
error={errors.labels?.[index]?.key?.message}
|
||||
@@ -386,6 +361,7 @@ export const LabelsWithoutSuggestions: FC = () => {
|
||||
</Field>
|
||||
<InlineLabel className={styles.equalSign}>=</InlineLabel>
|
||||
<Field
|
||||
noMargin
|
||||
className={styles.labelInput}
|
||||
invalid={!!errors.labels?.[index]?.value?.message}
|
||||
error={errors.labels?.[index]?.value?.message}
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import * as React from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
|
||||
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { mockAlertRuleApi, setupMswServer } from '../../../mockApi';
|
||||
import { getGrafanaRule } from '../../../mocks';
|
||||
import {
|
||||
defaultLabelValues,
|
||||
getLabelValuesHandler,
|
||||
getMockOpsLabels,
|
||||
} from '../../../mocks/server/handlers/plugins/grafana-labels-app';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||
|
||||
import { LabelsWithSuggestions } from './LabelsField';
|
||||
|
||||
// Existing labels in the form (simulating editing an existing alert rule with ops labels)
|
||||
const existingOpsLabels = getMockOpsLabels();
|
||||
|
||||
const SubFormProviderWrapper = ({
|
||||
children,
|
||||
labels,
|
||||
}: React.PropsWithChildren<{ labels: Array<{ key: string; value: string }> }>) => {
|
||||
const methods = useForm({ defaultValues: { labelsInSubform: labels } });
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
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(
|
||||
<SubFormProviderWrapper labels={labels}>
|
||||
<LabelsWithSuggestions dataSourceName="grafana" />
|
||||
</SubFormProviderWrapper>
|
||||
);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
+140
@@ -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<string, Set<string>>([
|
||||
['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<string, string[]> = {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+79
@@ -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<string, LabelItem[]> = {
|
||||
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<string, LabelItem[]> = 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<string, LabelItem[]> = 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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user