Alerting: Allow selecting the same custom group when swapping folders (#70337)

This commit is contained in:
Gilles De Mey 2023-06-20 11:28:24 +02:00 committed by GitHub
parent 815e98ed95
commit 87884f4d41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 64 additions and 97 deletions

View File

@ -1,4 +1,4 @@
import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import { render, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node';
import React from 'react'; import React from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@ -64,7 +64,6 @@ const ui = {
labelValue: (idx: number) => byTestId(`label-value-${idx}`), labelValue: (idx: number) => byTestId(`label-value-${idx}`),
}, },
loadingIndicator: byText('Loading the rule'), loadingIndicator: byText('Loading the rule'),
loadingGroupIndicator: byText('Loading...'),
}; };
function getProvidersWrapper() { function getProvidersWrapper() {
@ -149,7 +148,7 @@ describe('CloneRuleEditor', function () {
}); });
await waitForElementToBeRemoved(ui.loadingIndicator.query()); await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitForElementToBeRemoved(ui.loadingGroupIndicator.query(), { container: ui.inputs.group.get() }); await waitForElementToBeRemoved(within(ui.inputs.group.get()).getByTestId('Spinner'));
await waitFor(() => { await waitFor(() => {
expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)'); expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)');

View File

@ -244,7 +244,6 @@ export const AlertRuleForm = ({ existing, prefill, id }: Props) => {
<> <>
{type === RuleFormType.grafana ? ( {type === RuleFormType.grafana ? (
<GrafanaEvaluationBehavior <GrafanaEvaluationBehavior
initialFolder={defaultValues.folder}
evaluateEvery={evaluateEvery} evaluateEvery={evaluateEvery}
setEvaluateEvery={setEvaluateEvery} setEvaluateEvery={setEvaluateEvery}
existing={Boolean(existing)} existing={Boolean(existing)}

View File

@ -1,18 +1,18 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { debounce } from 'lodash'; import { debounce, take, uniqueId } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { AsyncSelect, Badge, Field, InputControl, Label, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { AsyncSelect, Badge, Field, InputControl, Label, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { AccessControlAction, useDispatch } from 'app/types'; import { AccessControlAction, useDispatch } from 'app/types';
import { CombinedRuleGroup } from 'app/types/unified-alerting'; import { CombinedRuleGroup } from 'app/types/unified-alerting';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions'; import { fetchRulerRulesAction } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form'; import { MINUTE } from '../../utils/rule-form';
@ -22,13 +22,17 @@ import { InfoIcon } from '../InfoIcon';
import { Folder, RuleFolderPicker } from './RuleFolderPicker'; import { Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util'; import { checkForPathSeparator } from './util';
export const SLICE_GROUP_RESULTS_TO = 1000; export const MAX_GROUP_RESULTS = 1000;
interface FolderAndGroupProps {
initialFolder: Folder | null;
}
export const useGetGroupOptionsFromFolder = (folderTitle: string) => { export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
const dispatch = useDispatch();
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
// for our folders
useEffect(() => {
dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
}, [dispatch]);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
@ -58,56 +62,33 @@ const sortByLabel = (a: SelectableValue<string>, b: SelectableValue<string>) =>
return a.label?.localeCompare(b.label ?? '') || 0; return a.label?.localeCompare(b.label ?? '') || 0;
}; };
export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) { const findGroupMatchingLabel = (group: SelectableValue<string>, query: string) => {
return group.label?.toLowerCase().includes(query.toLowerCase());
};
export function FolderAndGroup() {
const { const {
formState: { errors }, formState: { errors },
watch, watch,
setValue,
control, control,
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const folder = watch('folder'); const folder = watch('folder');
const group = watch('group'); const group = watch('group');
const [selectedGroup, setSelectedGroup] = useState<SelectableValue<string>>({ label: group, title: group });
const initialRender = useRef(true);
const { groupOptions, loading } = useGetGroupOptionsFromFolder(folder?.title ?? ''); const { groupOptions, loading } = useGetGroupOptionsFromFolder(folder?.title ?? '');
useEffect(() => setSelectedGroup({ label: group, title: group }), [group, setSelectedGroup]);
useEffect(() => {
dispatch(fetchRulerRulesIfNotFetchedYet(GRAFANA_RULES_SOURCE_NAME));
}, [dispatch]);
const resetGroup = useCallback(() => { const resetGroup = useCallback(() => {
if (group && !initialRender.current && folder?.title) { setValue('group', '');
setSelectedGroup({ label: '', title: '' }); }, [setValue]);
}
initialRender.current = false;
}, [group, folder?.title]);
const groupIsInGroupOptions = useCallback(
(group_: string) => {
return groupOptions.includes((groupInList: SelectableValue<string>) => groupInList.label === group_);
},
[groupOptions]
);
const sliceResults = (list: Array<SelectableValue<string>>) => list.slice(0, SLICE_GROUP_RESULTS_TO);
const getOptions = useCallback( const getOptions = useCallback(
async (query: string) => { async (query: string) => {
const results = query const results = query ? groupOptions.filter((group) => findGroupMatchingLabel(group, query)) : groupOptions;
? sliceResults( return take(results, MAX_GROUP_RESULTS);
groupOptions.filter((el) => {
const label = el.label ?? '';
return label.toLowerCase().includes(query.toLowerCase());
})
)
: sliceResults(groupOptions);
return results;
}, },
[groupOptions] [groupOptions]
); );
@ -116,6 +97,8 @@ export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) {
return debounce(getOptions, 300, { leading: true }); return debounce(getOptions, 300, { leading: true });
}, [getOptions]); }, [getOptions]);
const defaultGroupValue = group ? { value: group, label: group } : undefined;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Field <Field
@ -145,9 +128,7 @@ export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) {
enableReset={true} enableReset={true}
onChange={({ title, uid }) => { onChange={({ title, uid }) => {
field.onChange({ title, uid }); field.onChange({ title, uid });
if (!groupIsInGroupOptions(selectedGroup.value ?? '')) { resetGroup();
resetGroup();
}
}} }}
/> />
)} )}
@ -170,43 +151,40 @@ export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) {
invalid={!!errors.group?.message} invalid={!!errors.group?.message}
> >
<InputControl <InputControl
render={({ field: { ref, ...field }, fieldState }) => render={({ field: { ref, ...field }, fieldState }) => (
loading ? ( <AsyncSelect
<LoadingPlaceholder text="Loading..." /> disabled={!folder || loading}
) : ( inputId="group"
<AsyncSelect key={uniqueId()}
disabled={!folder} {...field}
inputId="group" onChange={(group) => {
key={`my_unique_select_key__${selectedGroup?.title ?? ''}`} field.onChange(group.label ?? '');
{...field} }}
invalid={Boolean(folder) && !selectedGroup.title && Boolean(fieldState.error)} isLoading={loading}
loadOptions={debouncedSearch} invalid={Boolean(folder) && !group && Boolean(fieldState.error)}
loadingMessage={'Loading groups...'} loadOptions={debouncedSearch}
defaultOptions={groupOptions} cacheOptions
defaultValue={selectedGroup} loadingMessage={'Loading groups...'}
getOptionLabel={(option: SelectableValue<string>) => ( defaultValue={defaultGroupValue}
<div> defaultOptions={groupOptions}
<span>{option.label}</span> getOptionLabel={(option: SelectableValue<string>) => (
{/* making the assumption here that it's provisioned when it's disabled, should probably change this */} <div>
{option.isDisabled && ( <span>{option.label}</span>
<> {/* making the assumption here that it's provisioned when it's disabled, should probably change this */}
{' '} {option.isDisabled && (
<Badge color="purple" text="Provisioned" /> <>
</> {' '}
)} <Badge color="purple" text="Provisioned" />
</div> </>
)} )}
placeholder={'Evaluation group name'} </div>
onChange={(value) => { )}
field.onChange(value.label ?? ''); placeholder={'Evaluation group name'}
}} allowCustomValue
value={selectedGroup} formatCreateLabel={(_) => '+ Add new '}
allowCustomValue noOptionsMessage="Start typing to create evaluation group"
formatCreateLabel={(_) => '+ Add new '} />
noOptionsMessage="Start typing to create evaluation group" )}
/>
)
}
name="group" name="group"
control={control} control={control}
rules={{ rules={{

View File

@ -21,7 +21,6 @@ import { EditCloudGroupModal, evaluateEveryValidationOptions } from '../rules/Ed
import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup'; import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
import { Folder } from './RuleFolderPicker';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -115,11 +114,9 @@ export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO |
}; };
function FolderGroupAndEvaluationInterval({ function FolderGroupAndEvaluationInterval({
initialFolder,
evaluateEvery, evaluateEvery,
setEvaluateEvery, setEvaluateEvery,
}: { }: {
initialFolder: Folder | null;
evaluateEvery: string; evaluateEvery: string;
setEvaluateEvery: (value: string) => void; setEvaluateEvery: (value: string) => void;
}) { }) {
@ -167,7 +164,7 @@ function FolderGroupAndEvaluationInterval({
return ( return (
<div> <div>
<FolderAndGroup initialFolder={initialFolder} /> <FolderAndGroup />
{folderName && isEditingGroup && ( {folderName && isEditingGroup && (
<EditCloudGroupModal <EditCloudGroupModal
namespace={existingNamespace ?? emptyNamespace} namespace={existingNamespace ?? emptyNamespace}
@ -249,12 +246,10 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
} }
export function GrafanaEvaluationBehavior({ export function GrafanaEvaluationBehavior({
initialFolder,
evaluateEvery, evaluateEvery,
setEvaluateEvery, setEvaluateEvery,
existing, existing,
}: { }: {
initialFolder: Folder | null;
evaluateEvery: string; evaluateEvery: string;
setEvaluateEvery: (value: string) => void; setEvaluateEvery: (value: string) => void;
existing: boolean; existing: boolean;
@ -270,11 +265,7 @@ export function GrafanaEvaluationBehavior({
// TODO remove "and alert condition" for recording rules // TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Alert evaluation behavior"> <RuleEditorSection stepNo={3} title="Alert evaluation behavior">
<Stack direction="column" justify-content="flex-start" align-items="flex-start"> <Stack direction="column" justify-content="flex-start" align-items="flex-start">
<FolderGroupAndEvaluationInterval <FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} />
initialFolder={initialFolder}
setEvaluateEvery={setEvaluateEvery}
evaluateEvery={evaluateEvery}
/>
<ForInput evaluateEvery={evaluateEvery} /> <ForInput evaluateEvery={evaluateEvery} />
{existing && ( {existing && (