import { css } from '@emotion/css'; import { uniq } from 'lodash'; import { useState, useMemo } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { SelectableValue } from '@grafana/data'; import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; import { FetchError, getTemplateSrv, isFetchError } from '@grafana/runtime'; import { Select, HorizontalGroup, useStyles2, InputActionMeta } from '@grafana/ui'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; import { TempoDatasource } from '../datasource'; import { OPTIONS_LIMIT } from '../language_provider'; import { operators as allOperators, stringOperators, numberOperators, keywordOperators } from '../traceql/traceql'; import { filterScopedTag, operatorSelectableValue } from './utils'; interface Props { filter: TraceqlFilter; datasource: TempoDatasource; updateFilter: (f: TraceqlFilter) => void; deleteFilter?: (f: TraceqlFilter) => void; setError: (error: FetchError | null) => void; isTagsLoading?: boolean; tags: string[]; hideScope?: boolean; hideTag?: boolean; hideValue?: boolean; query: string; isMulti?: boolean; allowCustomValue?: boolean; addVariablesToOptions?: boolean; } const SearchField = ({ filter, datasource, updateFilter, isTagsLoading, tags, setError, hideScope, hideTag, hideValue, query, addVariablesToOptions, isMulti = true, allowCustomValue = true, }: Props) => { const styles = useStyles2(getStyles); const [alertText, setAlertText] = useState(); const scopedTag = useMemo( () => filterScopedTag(filter, datasource.languageProvider), [datasource.languageProvider, filter] ); const [tagQuery, setTagQuery] = useState(''); const [tagValuesQuery, setTagValuesQuery] = useState(''); const updateOptions = async () => { try { const result = filter.tag ? await datasource.languageProvider.getOptionsV2(scopedTag, query) : []; setAlertText(undefined); setError(null); return result; } catch (error) { // Display message if Tempo is connected but search 404's if (isFetchError(error) && error?.status === 404) { setError(error); } else if (error instanceof Error) { setAlertText(`Error: ${error.message}`); } } return []; }; const { loading: isLoadingValues, value: options } = useAsync(updateOptions, [ scopedTag, datasource.languageProvider, setError, query, ]); // Add selected option if it doesn't exist in the current list of options if (filter.value && !Array.isArray(filter.value) && options && !options.find((o) => o.value === filter.value)) { options.push({ label: filter.value.toString(), value: filter.value.toString(), type: filter.valueType }); } const scopeOptions = Object.values(TraceqlSearchScope) .filter((s) => { // only add scope if it has tags return datasource.languageProvider.getTags(s).length > 0; }) .map((t) => ({ label: t, value: t })); // If all values have type string or int/float use a focused list of operators instead of all operators const optionsOfFirstType = options?.filter((o) => o.type === options[0]?.type); const uniqueOptionType = options?.length === optionsOfFirstType?.length ? options?.[0]?.type : undefined; let operatorList = allOperators; switch (uniqueOptionType) { case 'keyword': operatorList = keywordOperators; break; case 'string': operatorList = stringOperators; break; case 'int': case 'float': operatorList = numberOperators; } const operatorOptions = operatorList.map(operatorSelectableValue); const formatTagOptions = (tags: string[], filterTag: string | undefined) => { return (filterTag !== undefined ? uniq([filterTag, ...tags]) : tags).map((t) => ({ label: t, value: t })); }; const tagOptions = useMemo(() => { if (tagQuery.length === 0) { return formatTagOptions(tags.slice(0, OPTIONS_LIMIT), filter.tag); } const queryLowerCase = tagQuery.toLowerCase(); const filterdOptions = tags.filter((tag) => tag.toLowerCase().includes(queryLowerCase)).slice(0, OPTIONS_LIMIT); return formatTagOptions(filterdOptions, filter.tag); }, [filter.tag, tagQuery, tags]); const tagValueOptions = useMemo(() => { if (!options) { return; } if (tagValuesQuery.length === 0) { return options.slice(0, OPTIONS_LIMIT); } const queryLowerCase = tagValuesQuery.toLowerCase(); return options .filter((tag) => { if (tag.value && tag.value.length > 0) { return tag.value.toLowerCase().includes(queryLowerCase); } return false; }) .slice(0, OPTIONS_LIMIT); }, [tagValuesQuery, options]); return ( <> {!hideScope && ( { if (action === 'input-change') { setTagQuery(value); } }} onCloseMenu={() => setTagQuery('')} onChange={(v) => updateFilter({ ...filter, tag: v?.value, value: [] })} value={filter.tag} key={filter.tag} placeholder="Select tag" isClearable aria-label={`select ${filter.id} tag`} allowCustomValue virtualized /> )} { if (action === 'input-change') { setTagValuesQuery(value); } }} onCloseMenu={() => setTagValuesQuery('')} onChange={(val) => { if (Array.isArray(val)) { updateFilter({ ...filter, value: val.map((v) => v.value), valueType: val[0]?.type || uniqueOptionType, }); } else { updateFilter({ ...filter, value: val?.value, valueType: val?.type || uniqueOptionType }); } }} placeholder="Select value" isClearable={true} aria-label={`select ${filter.id} value`} allowCustomValue={allowCustomValue} isMulti={isMulti} allowCreateWhileLoading /> )} {alertText && } ); }; export default SearchField; /** * Add to a list of options the current template variables. * * @param options a list of options * @returns the list of given options plus the template variables */ export const withTemplateVariableOptions = (options: SelectableValue[] | undefined) => { const templateVariables = getTemplateSrv().getVariables(); return [...(options || []), ...templateVariables.map((v) => ({ label: `$${v.name}`, value: `$${v.name}` }))]; }; const getStyles = () => ({ dropdown: css({ boxShadow: 'none', }), });