mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search: Update tag filter options dynamically (#47165)
This commit is contained in:
parent
ebe5f3646f
commit
99bb6ebd2b
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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};
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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: '',
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user