Search: Update tag filter options dynamically (#47165)

This commit is contained in:
Nathan Marrs 2022-04-08 12:18:52 -07:00 committed by GitHub
parent ebe5f3646f
commit 99bb6ebd2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 113 additions and 47 deletions

View File

@ -128,6 +128,7 @@ export function SelectBase<T>({
onInputChange,
onKeyDown,
onOpenMenu,
onFocus,
openMenuOnFocus = false,
options = [],
placeholder = 'Choose',
@ -235,6 +236,7 @@ export function SelectBase<T>({
onKeyDown,
onMenuClose: onCloseMenu,
onMenuOpen: onOpenMenu,
onFocus,
formatOptionLabel,
openMenuOnFocus,
options,

View File

@ -61,6 +61,7 @@ export interface SelectCommonProps<T> {
onInputChange?: (value: string, actionMeta: InputActionMeta) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
onOpenMenu?: () => void;
onFocus?: () => void;
openMenuOnFocus?: boolean;
options?: Array<SelectableValue<T>>;
placeholder?: string;

View File

@ -1,11 +1,9 @@
// Libraries
import React, { FC } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { components } from 'react-select';
import debounce from 'debounce-promise';
import { stylesFactory, useTheme, Icon, AsyncMultiSelect } from '@grafana/ui';
import { escapeStringForRegex, GrafanaTheme } from '@grafana/data';
// Components
import { Icon, MultiSelect, useStyles2 } from '@grafana/ui';
import { escapeStringForRegex, GrafanaTheme2 } from '@grafana/data';
import { TagOption } from './TagOption';
import { TagBadge } from './TagBadge';
@ -14,6 +12,12 @@ export interface TermCount {
count: number;
}
interface TagSelectOption {
value: string;
label: string;
count: number;
}
export interface Props {
allowCustomValue?: boolean;
formatCreateLabel?: (input: string) => string;
@ -45,29 +49,70 @@ export const TagFilter: FC<Props> = ({
tags,
width,
}) => {
const theme = useTheme();
const styles = getStyles(theme);
const styles = useStyles2(getStyles);
const onLoadOptions = async (query: string) => {
const currentlySelectedTags = tags.map((tag) => ({ value: tag, label: tag, count: 0 }));
const [options, setOptions] = useState<TagSelectOption[]>(currentlySelectedTags);
const [isLoading, setIsLoading] = useState(false);
const [previousTags, setPreviousTags] = useState(tags);
// Necessary to force re-render to keep tag options up to date / relevant
const selectKey = useMemo(() => tags.join(), [tags]);
const onLoadOptions = useCallback(async () => {
const options = await tagOptions();
return options.map((option) => ({
value: option.term,
label: option.term,
count: option.count,
}));
};
return options.map((option) => {
if (tags.includes(option.term)) {
return {
value: option.term,
label: option.term,
count: 0,
};
} else {
return {
value: option.term,
label: option.term,
count: option.count,
};
}
});
}, [tagOptions, tags]);
const debouncedLoadOptions = debounce(onLoadOptions, 300);
const onFocus = useCallback(async () => {
setIsLoading(true);
const results = await onLoadOptions();
setOptions(results);
setIsLoading(false);
}, [onLoadOptions]);
useEffect(() => {
// Load options when tag is selected externally
if (tags.length > 0 && options.length === 0) {
onFocus();
}
}, [onFocus, options.length, tags.length]);
useEffect(() => {
// Update selected tags to not include (counts) when selected externally
if (tags !== previousTags) {
setPreviousTags(tags);
onFocus();
}
}, [onFocus, previousTags, tags]);
const onTagChange = (newTags: any[]) => {
// On remove with 1 item returns null, so we need to make sure it's an empty array in that case
// https://github.com/JedWatson/react-select/issues/3632
newTags.forEach((tag) => (tag.count = 0));
onChange((newTags || []).map((tag) => tag.value));
};
const value = tags.map((tag) => ({ value: tag, label: tag, count: 0 }));
const selectOptions = {
key: selectKey,
onFocus,
isLoading,
options,
allowCreateWhileLoading: true,
allowCustomValue,
formatCreateLabel,
@ -77,12 +122,11 @@ export const TagFilter: FC<Props> = ({
getOptionValue: (i: any) => i.value,
inputId,
isMulti: true,
loadOptions: debouncedLoadOptions,
loadingMessage: 'Loading...',
noOptionsMessage: 'No tags found',
onChange: onTagChange,
placeholder,
value,
value: currentlySelectedTags,
width,
components: {
Option: TagOption,
@ -109,37 +153,35 @@ export const TagFilter: FC<Props> = ({
Clear tags
</span>
)}
<AsyncMultiSelect menuShouldPortal {...selectOptions} prefix={<Icon name="tag-alt" />} aria-label="Tag filter" />
<MultiSelect menuShouldPortal {...selectOptions} prefix={<Icon name="tag-alt" />} aria-label="Tag filter" />
</div>
);
};
TagFilter.displayName = 'TagFilter';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
tagFilter: css`
position: relative;
min-width: 180px;
flex-grow: 1;
const getStyles = (theme: GrafanaTheme2) => ({
tagFilter: css`
position: relative;
min-width: 180px;
flex-grow: 1;
.label-tag {
margin-left: 6px;
cursor: pointer;
}
`,
clear: css`
text-decoration: underline;
font-size: 12px;
position: absolute;
top: -22px;
right: 0;
.label-tag {
margin-left: 6px;
cursor: pointer;
color: ${theme.colors.textWeak};
}
`,
clear: css`
text-decoration: underline;
font-size: 12px;
position: absolute;
top: -22px;
right: 0;
cursor: pointer;
color: ${theme.colors.text.secondary};
&:hover {
color: ${theme.colors.textStrong};
}
`,
};
&:hover {
color: ${theme.colors.text.primary};
}
`,
});

View File

@ -12,6 +12,12 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
TagFilter: () => {
return <>mocked-tag-filter</>;
},
}));
async function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
jest.clearAllMocks();
const playlist = { name, items, interval } as unknown as Playlist;

View File

@ -6,6 +6,12 @@ import userEvent from '@testing-library/user-event';
import { Playlist } from './types';
import { PlaylistForm } from './PlaylistForm';
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
TagFilter: () => {
return <>mocked-tag-filter</>;
},
}));
function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
const onSubmitMock = jest.fn();
const playlist = { name, items, interval } as unknown as Playlist;

View File

@ -19,6 +19,12 @@ jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => backendSrv,
}));
jest.mock('../../core/components/TagFilter/TagFilter', () => ({
TagFilter: () => {
return <>mocked-tag-filter</>;
},
}));
function getTestContext({ name, interval, items }: Partial<Playlist> = {}) {
jest.clearAllMocks();
const playlist = { name, items, interval } as unknown as Playlist;

View File

@ -112,10 +112,11 @@ describe('DashboardSearch', () => {
await waitFor(() => screen.getByLabelText('Tag filter'));
const tagComponent = screen.getByLabelText('Tag filter');
await selectOptionInTest(tagComponent, 'tag1');
expect(tagComponent).toBeInTheDocument();
tagComponent.focus();
await waitFor(() => selectOptionInTest(tagComponent, 'tag1'));
await waitFor(() =>
expect(mockSearch).toHaveBeenCalledWith({
query: '',

View File

@ -83,6 +83,7 @@ describe('SearchResultsFilter', () => {
query: { ...searchQuery, tag: [] },
});
const tagComponent = await screen.findByLabelText('Tag filter');
await tagComponent.focus();
await selectOptionInTest(tagComponent, 'tag1');
expect(mockFilterByTags).toHaveBeenCalledTimes(1);

View File

@ -63,7 +63,8 @@ export default function SearchPage() {
{results.loading && <Spinner />}
{results.value?.body && (
<div>
<TagFilter isClearable tags={query.tag} tagOptions={getTagOptions} onChange={onTagChange} /> <br />
<TagFilter isClearable tags={query.tag} tagOptions={getTagOptions} onChange={onTagChange} />
<br />
{query.datasource && (
<Button
icon="times"