diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index bd313b231e7..b74dc5258d0 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -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, diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 4b93366dc86..12606c768c0 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -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; diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index c69fd5ac889..c419f6a8db7 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -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}; + } + `, }); diff --git a/public/app/features/playlist/PlaylistEditPage.test.tsx b/public/app/features/playlist/PlaylistEditPage.test.tsx index 0d8941313fb..2766c0fe6c4 100644 --- a/public/app/features/playlist/PlaylistEditPage.test.tsx +++ b/public/app/features/playlist/PlaylistEditPage.test.tsx @@ -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; diff --git a/public/app/features/playlist/PlaylistForm.test.tsx b/public/app/features/playlist/PlaylistForm.test.tsx index 8a5a774579e..b401838b698 100644 --- a/public/app/features/playlist/PlaylistForm.test.tsx +++ b/public/app/features/playlist/PlaylistForm.test.tsx @@ -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; diff --git a/public/app/features/playlist/PlaylistNewPage.test.tsx b/public/app/features/playlist/PlaylistNewPage.test.tsx index f3469adba79..487bd152607 100644 --- a/public/app/features/playlist/PlaylistNewPage.test.tsx +++ b/public/app/features/playlist/PlaylistNewPage.test.tsx @@ -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; diff --git a/public/app/features/search/components/DashboardSearch.test.tsx b/public/app/features/search/components/DashboardSearch.test.tsx index 029ab069a51..8004154fc21 100644 --- a/public/app/features/search/components/DashboardSearch.test.tsx +++ b/public/app/features/search/components/DashboardSearch.test.tsx @@ -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: '', diff --git a/public/app/features/search/components/SearchResultsFilter.test.tsx b/public/app/features/search/components/SearchResultsFilter.test.tsx index ff587ac1ec7..e88ce063da2 100644 --- a/public/app/features/search/components/SearchResultsFilter.test.tsx +++ b/public/app/features/search/components/SearchResultsFilter.test.tsx @@ -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); diff --git a/public/app/features/search/page/SearchPage.tsx b/public/app/features/search/page/SearchPage.tsx index a9e8ced3802..923f677bc43 100644 --- a/public/app/features/search/page/SearchPage.tsx +++ b/public/app/features/search/page/SearchPage.tsx @@ -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"