|
|
|
@ -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}; |
|
|
|
|
} |
|
|
|
|
`,
|
|
|
|
|
}); |
|
|
|
|