diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index 35aa3f6e33..bc7002e7dc 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -264,7 +264,8 @@ function LogsExplorerViews({ undefined, listQueryKeyRef, { - ...(!isEmpty(queryId) && { 'X-SIGNOZ-QUERY-ID': queryId }), + ...(!isEmpty(queryId) && + selectedPanelType !== PANEL_TYPES.LIST && { 'X-SIGNOZ-QUERY-ID': queryId }), }, ); diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx new file mode 100644 index 0000000000..c910ca1be4 --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchDropdown.tsx @@ -0,0 +1,112 @@ +/* eslint-disable no-nested-ternary */ +import { Typography } from 'antd'; +import { + ArrowDown, + ArrowUp, + ChevronUp, + Command, + CornerDownLeft, + Slash, +} from 'lucide-react'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS'; + +import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs'; +import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils'; +import { ITag, Option } from './QueryBuilderSearchV2'; + +interface ICustomDropdownProps { + menu: React.ReactElement; + searchValue: string; + tags: ITag[]; + options: Option[]; + exampleQueries: TagFilter[]; + onChange: (value: TagFilter) => void; + currentFilterItem?: ITag; +} + +export default function QueryBuilderSearchDropdown( + props: ICustomDropdownProps, +): React.ReactElement { + const { + menu, + currentFilterItem, + searchValue, + tags, + exampleQueries, + options, + onChange, + } = props; + const userOs = getUserOperatingSystem(); + return ( + <> +
+ {!currentFilterItem?.key ? ( +
Suggested Filters
+ ) : !currentFilterItem?.op ? ( +
+ + Operator for{' '} + + + {currentFilterItem?.key?.key} + +
+ ) : ( +
+ + Value(s) for{' '} + + + {currentFilterItem?.key?.key} {currentFilterItem?.op} + +
+ )} + {menu} + {!searchValue && tags.length === 0 && ( +
+
Example Queries
+
+ {convertExampleQueriesToOptions(exampleQueries).map((query) => ( + + ))} +
+
+ )} +
+ +
+
+ + + to navigate +
+
+ + to update query +
+ {!currentFilterItem?.key && options.length > 3 && ( +
+ {userOs === UserOperatingSystem.MACOS ? ( + + ) : ( + + )} + + + + Show all filter items +
+ )} +
+ + ); +} + +QueryBuilderSearchDropdown.defaultProps = { + currentFilterItem: undefined, +}; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss new file mode 100644 index 0000000000..1ca8bd7529 --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.styles.scss @@ -0,0 +1,261 @@ +.query-builder-search-v2 { + display: flex; + gap: 4px; + + .show-all-filters { + .content { + .rc-virtual-list-holder { + height: 100px; + } + } + } + + .content { + .suggested-filters { + color: var(--bg-slate-50); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; + letter-spacing: 0.88px; + text-transform: uppercase; + padding: 12px 0px 8px 14px; + } + + .operator-for { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 0px 8px 14px; + + .operator-for-text { + color: var(--bg-slate-200); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.88px; + text-transform: uppercase; + } + + .operator-for-value { + display: flex; + align-items: center; + height: 20px; + padding: 0px 8px; + justify-content: center; + gap: 4px; + border-radius: 50px; + background: rgba(255, 255, 255, 0.1); + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: -0.06px; + } + } + + .value-for { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 0px 8px 14px; + .value-for-text { + color: var(--bg-slate-200); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.88px; + text-transform: uppercase; + } + + .value-for-value { + display: flex; + align-items: center; + height: 20px; + padding: 0px 8px; + justify-content: center; + gap: 4px; + border-radius: 50px; + background: rgba(255, 255, 255, 0.1); + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: -0.06px; + } + } + .example-queries { + cursor: default; + .heading { + padding: 12px 14px 8px 14px; + color: var(--bg-slate-50); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.88px; + text-transform: uppercase; + } + + .query-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0px 12px 12px 12px; + cursor: pointer; + + .example-query { + display: flex; + padding: 4px 8px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + background: var(--bg-ink-200); + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: normal; + letter-spacing: -0.07px; + width: fit-content; + } + + .example-query:hover { + color: var(--bg-vanilla-100); + } + } + } + } + + .keyboard-shortcuts { + display: flex; + align-items: center; + border-radius: 0px 0px 4px 4px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + padding: 11px 16px; + cursor: default; + + .icons { + width: 16px; + height: 16px; + flex-shrink: 0; + border-radius: 2.286px; + border-top: 1.143px solid var(--bg-ink-200); + border-right: 1.143px solid var(--bg-ink-200); + border-bottom: 2.286px solid var(--bg-ink-200); + border-left: 1.143px solid var(--bg-ink-200); + background: var(--Ink-400, #121317); + } + + .keyboard-text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 142.857% */ + letter-spacing: -0.07px; + } + + .navigate { + display: flex; + align-items: center; + padding-right: 12px; + gap: 4px; + border-right: 1px solid #1d212d; + } + + .update-query { + display: flex; + align-items: center; + margin-left: 12px; + gap: 4px; + } + .show-all-filter-items { + padding-left: 12px; + border-left: 1px solid #1d212d; + display: flex; + align-items: center; + margin-left: 12px; + gap: 4px; + } + } + + .search-bar { + width: 100%; + } + + .qb-search-bar-tokenised-tags { + .ant-tag { + display: flex; + align-items: center; + border-radius: 2px 0px 0px 2px; + border: 1px solid var(--bg-slate-300); + background: var(--bg-slate-300); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + padding: 0px; + + .ant-typography { + color: var(--bg-vanilla-100); + font-family: Inter; + font-size: 14px !important; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 142.857% */ + padding: 2px 6px; + } + + .ant-tag-close-icon { + display: flex; + align-items: center; + justify-content: center; + border-radius: 0px 2px 2px 0px; + width: 20px; + height: 24px; + flex-shrink: 0; + margin-inline-start: 0px !important; + margin-inline-end: 0px !important; + } + + &.resource { + border: 1px solid rgba(242, 71, 105, 0.2); + + .ant-typography { + color: var(--bg-sakura-400); + background: rgba(245, 108, 135, 0.1); + font-size: 14px; + } + + .ant-tag-close-icon { + background: rgba(245, 108, 135, 0.1); + } + } + &.tag { + border: 1px solid rgba(189, 153, 121, 0.2); + + .ant-typography { + color: var(--bg-sienna-400); + background: rgba(189, 153, 121, 0.1); + font-size: 14px; + } + + .ant-tag-close-icon { + background: rgba(189, 153, 121, 0.1); + } + } + } + } +} diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx new file mode 100644 index 0000000000..d53a517c48 --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -0,0 +1,862 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import './QueryBuilderSearchV2.styles.scss'; + +import { Select, Spin, Tag, Tooltip } from 'antd'; +import cx from 'classnames'; +import { + OPERATORS, + QUERY_BUILDER_OPERATORS_BY_TYPES, + QUERY_BUILDER_SEARCH_VALUES, +} from 'constants/queryBuilder'; +import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig'; +import ROUTES from 'constants/routes'; +import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts'; +import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; +import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete'; +import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions'; +import { validationMapper } from 'hooks/queryBuilder/useIsValidTag'; +import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import useDebounceValue from 'hooks/useDebounce'; +import { + cloneDeep, + isArray, + isEmpty, + isEqual, + isObject, + isUndefined, + unset, +} from 'lodash-es'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import type { BaseSelectRef } from 'rc-select'; +import { + KeyboardEvent, + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; +import { + BaseAutocompleteData, + DataTypes, +} from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { + IBuilderQuery, + TagFilter, +} from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { popupContainer } from 'utils/selectPopupContainer'; +import { v4 as uuid } from 'uuid'; + +import { selectStyle } from '../QueryBuilderSearch/config'; +import { PLACEHOLDER } from '../QueryBuilderSearch/constant'; +import { TypographyText } from '../QueryBuilderSearch/style'; +import { getTagToken, isInNInOperator } from '../QueryBuilderSearch/utils'; +import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown'; +import Suggestions from './Suggestions'; + +export interface ITag { + id?: string; + key: BaseAutocompleteData; + op: string; + value: string[] | string | number | boolean; +} + +interface CustomTagProps { + label: React.ReactNode; + value: string; + disabled: boolean; + onClose: () => void; + closable: boolean; +} + +interface QueryBuilderSearchV2Props { + query: IBuilderQuery; + onChange: (value: TagFilter) => void; + whereClauseConfig?: WhereClauseConfig; + placeholder?: string; + className?: string; + suffixIcon?: React.ReactNode; +} + +export interface Option { + label: string; + value: BaseAutocompleteData | string; +} + +export enum DropdownState { + ATTRIBUTE_KEY = 'ATTRIBUTE_KEY', + OPERATOR = 'OPERATOR', + ATTRIBUTE_VALUE = 'ATTRIBUTE_VALUE', +} + +function getInitTags(query: IBuilderQuery): ITag[] { + return query.filters.items.map((item) => ({ + id: item.id, + key: item.key as BaseAutocompleteData, + op: item.op, + value: `${item.value}`, + })); +} + +function QueryBuilderSearchV2( + props: QueryBuilderSearchV2Props, +): React.ReactElement { + const { + query, + onChange, + placeholder, + className, + suffixIcon, + whereClauseConfig, + } = props; + + const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); + + const { handleRunQuery, currentQuery } = useQueryBuilder(); + + const selectRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + + // create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync. + const [tags, setTags] = useState(() => getInitTags(query)); + + // this will maintain the current state of in process filter item + const [currentFilterItem, setCurrentFilterItem] = useState(); + + const [currentState, setCurrentState] = useState( + DropdownState.ATTRIBUTE_KEY, + ); + + // to maintain the current running state until the tokenization happens for the tag + const [searchValue, setSearchValue] = useState(''); + + const [dropdownOptions, setDropdownOptions] = useState([]); + + const [showAllFilters, setShowAllFilters] = useState(false); + + const { pathname } = useLocation(); + const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [ + pathname, + ]); + + const memoizedSearchParams = useMemo( + () => [ + searchValue, + query.dataSource, + query.aggregateOperator, + query.aggregateAttribute.key, + ], + [ + searchValue, + query.dataSource, + query.aggregateOperator, + query.aggregateAttribute.key, + ], + ); + + const queryFiltersWithoutId = useMemo( + () => ({ + ...query.filters, + items: query.filters.items.map((item) => { + const filterWithoutId = cloneDeep(item); + unset(filterWithoutId, 'id'); + return filterWithoutId; + }), + }), + [query.filters], + ); + + const memoizedSuggestionsParams = useMemo( + () => [searchValue, query.dataSource, queryFiltersWithoutId], + [query.dataSource, queryFiltersWithoutId, searchValue], + ); + + const memoizedValueParams = useMemo( + () => [ + query.aggregateOperator, + query.dataSource, + query.aggregateAttribute.key, + currentFilterItem?.key?.key || '', + currentFilterItem?.key?.dataType, + currentFilterItem?.key?.type ?? '', + isArray(currentFilterItem?.value) + ? currentFilterItem?.value?.[currentFilterItem.value.length - 1] + : currentFilterItem?.value, + ], + [ + query.aggregateOperator, + query.dataSource, + query.aggregateAttribute.key, + currentFilterItem?.key?.key, + currentFilterItem?.key?.dataType, + currentFilterItem?.key?.type, + currentFilterItem?.value, + ], + ); + + const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY); + + const valueParams = useDebounceValue(memoizedValueParams, DEBOUNCE_DELAY); + + const suggestionsParams = useDebounceValue( + memoizedSuggestionsParams, + DEBOUNCE_DELAY, + ); + + const isQueryEnabled = useMemo(() => { + if (currentState === DropdownState.ATTRIBUTE_KEY) { + return query.dataSource === DataSource.METRICS + ? !!query.aggregateOperator && + !!query.dataSource && + !!query.aggregateAttribute.dataType + : true; + } + return false; + }, [ + currentState, + query.aggregateAttribute.dataType, + query.aggregateOperator, + query.dataSource, + ]); + + const { data, isFetching } = useGetAggregateKeys( + { + searchText: searchValue, + dataSource: query.dataSource, + aggregateOperator: query.aggregateOperator, + aggregateAttribute: query.aggregateAttribute.key, + tagType: query.aggregateAttribute.type ?? null, + }, + { + queryKey: [searchParams], + enabled: isQueryEnabled && !isLogsExplorerPage, + }, + ); + + const { + data: suggestionsData, + isFetching: isFetchingSuggestions, + } = useGetAttributeSuggestions( + { + searchText: searchValue.split(' ')[0], + dataSource: query.dataSource, + filters: query.filters, + }, + { + queryKey: [suggestionsParams], + enabled: isQueryEnabled && isLogsExplorerPage, + }, + ); + + const { + data: attributeValues, + isFetching: isFetchingAttributeValues, + } = useGetAggregateValues( + { + aggregateOperator: query.aggregateOperator, + dataSource: query.dataSource, + aggregateAttribute: query.aggregateAttribute.key, + attributeKey: currentFilterItem?.key?.key || '', + filterAttributeKeyDataType: + currentFilterItem?.key?.dataType ?? DataTypes.EMPTY, + tagType: currentFilterItem?.key?.type ?? '', + searchText: isArray(currentFilterItem?.value) + ? currentFilterItem?.value?.[currentFilterItem.value.length - 1] || '' + : currentFilterItem?.value?.toString() || '', + }, + { + enabled: currentState === DropdownState.ATTRIBUTE_VALUE, + queryKey: [valueParams], + }, + ); + + const handleDropdownSelect = useCallback( + (value: string) => { + let parsedValue: BaseAutocompleteData | string; + + try { + parsedValue = JSON.parse(value); + } catch { + parsedValue = value; + } + if (currentState === DropdownState.ATTRIBUTE_KEY) { + setCurrentFilterItem((prev) => ({ + ...prev, + key: parsedValue as BaseAutocompleteData, + op: '', + value: '', + })); + setCurrentState(DropdownState.OPERATOR); + setSearchValue((parsedValue as BaseAutocompleteData)?.key); + } else if (currentState === DropdownState.OPERATOR) { + if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) { + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key, + op: value, + value: '', + } as ITag, + ]); + setCurrentFilterItem(undefined); + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } else { + setCurrentFilterItem((prev) => ({ + key: prev?.key as BaseAutocompleteData, + op: value as string, + value: '', + })); + setCurrentState(DropdownState.ATTRIBUTE_VALUE); + setSearchValue(`${currentFilterItem?.key?.key} ${value}`); + } + } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { + const operatorType = + operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; + const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY; + + if (isMulti) { + const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); + const newSearch = [...tagValue]; + newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value; + const newSearchValue = newSearch.join(','); + setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`); + } else { + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + setCurrentFilterItem(undefined); + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key, + op: currentFilterItem?.op, + value, + } as ITag, + ]); + } + } + }, + [currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue], + ); + + const handleSearch = useCallback((value: string) => { + setSearchValue(value); + }, []); + + const onInputKeyDownHandler = useCallback( + (event: KeyboardEvent): void => { + if (event.key === 'Backspace' && !searchValue) { + event.stopPropagation(); + setTags((prev) => prev.slice(0, -1)); + } + if ((event.ctrlKey || event.metaKey) && event.key === '/') { + event.preventDefault(); + event.stopPropagation(); + setShowAllFilters((prev) => !prev); + } + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + handleRunQuery(); + setIsOpen(false); + } + }, + [handleRunQuery, searchValue], + ); + + const handleOnBlur = useCallback((): void => { + if (searchValue) { + const operatorType = + operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; + if ( + currentFilterItem?.key && + isEmpty(currentFilterItem?.op) && + whereClauseConfig?.customKey === 'body' && + whereClauseConfig?.customOp === OPERATORS.CONTAINS + ) { + setTags((prev) => [ + ...prev, + { + key: { + key: 'body', + dataType: DataTypes.String, + type: '', + isColumn: true, + isJSON: false, + id: 'body--string----true', + }, + op: OPERATORS.CONTAINS, + value: currentFilterItem?.key?.key, + }, + ]); + setCurrentFilterItem(undefined); + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } else if ( + currentFilterItem?.op === OPERATORS.EXISTS || + currentFilterItem?.op === OPERATORS.NOT_EXISTS + ) { + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key, + op: currentFilterItem?.op, + value: '', + }, + ]); + setCurrentFilterItem(undefined); + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } else if ( + validationMapper[operatorType]?.( + isArray(currentFilterItem?.value) + ? currentFilterItem?.value.length || 0 + : 1, + ) + ) { + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key as BaseAutocompleteData, + op: currentFilterItem?.op as string, + value: currentFilterItem?.value || '', + }, + ]); + setCurrentFilterItem(undefined); + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } + } + }, [ + currentFilterItem?.key, + currentFilterItem?.op, + currentFilterItem?.value, + searchValue, + whereClauseConfig?.customKey, + whereClauseConfig?.customOp, + ]); + + // this useEffect takes care of tokenisation based on the search state + useEffect(() => { + if (isFetchingSuggestions) { + return; + } + if (!searchValue) { + setCurrentFilterItem(undefined); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } + const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); + + if (tagKey && isUndefined(currentFilterItem?.key)) { + let currentRunningAttributeKey; + const isSuggestedKeyInAutocomplete = suggestionsData?.payload?.attributes?.some( + (value) => value.key === tagKey.split(' ')[0], + ); + + if (isSuggestedKeyInAutocomplete) { + const allAttributesMatchingTheKey = + suggestionsData?.payload?.attributes?.filter( + (value) => value.key === tagKey.split(' ')[0], + ) || []; + + if (allAttributesMatchingTheKey?.length === 1) { + [currentRunningAttributeKey] = allAttributesMatchingTheKey; + } + if (allAttributesMatchingTheKey?.length > 1) { + // the priority logic goes here + [currentRunningAttributeKey] = allAttributesMatchingTheKey; + } + + if (currentRunningAttributeKey) { + setCurrentFilterItem({ + key: currentRunningAttributeKey, + op: '', + value: '', + }); + + setCurrentState(DropdownState.OPERATOR); + } + } + if (suggestionsData?.payload?.attributes?.length === 0) { + setCurrentFilterItem({ + key: { + key: tagKey.split(' ')[0], + // update this for has and nhas operator , check the useEffect of source keys in older component for details + dataType: DataTypes.EMPTY, + type: '', + isColumn: false, + isJSON: false, + }, + op: '', + value: '', + }); + setCurrentState(DropdownState.OPERATOR); + } + } else if ( + currentFilterItem?.key && + currentFilterItem?.key?.key !== tagKey.split(' ')[0] + ) { + setCurrentFilterItem(undefined); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } else if (tagOperator && isEmpty(currentFilterItem?.op)) { + if ( + tagOperator === OPERATORS.EXISTS || + tagOperator === OPERATORS.NOT_EXISTS + ) { + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key, + op: tagOperator, + value: '', + } as ITag, + ]); + setCurrentFilterItem(undefined); + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + } else { + setCurrentFilterItem((prev) => ({ + key: prev?.key as BaseAutocompleteData, + op: tagOperator, + value: '', + })); + + setCurrentState(DropdownState.ATTRIBUTE_VALUE); + } + } else if ( + !isEmpty(currentFilterItem?.op) && + tagOperator !== currentFilterItem?.op + ) { + setCurrentFilterItem((prev) => ({ + key: prev?.key as BaseAutocompleteData, + op: '', + value: '', + })); + setCurrentState(DropdownState.OPERATOR); + } else if (!isEmpty(tagValue)) { + const currentValue = { + key: currentFilterItem?.key as BaseAutocompleteData, + operator: currentFilterItem?.op as string, + value: tagValue, + }; + if (!isEqual(currentValue, currentFilterItem)) { + setCurrentFilterItem((prev) => ({ + key: prev?.key as BaseAutocompleteData, + op: prev?.op as string, + value: tagValue, + })); + } + } + }, [ + currentFilterItem, + currentFilterItem?.key, + currentFilterItem?.op, + suggestionsData?.payload?.attributes, + searchValue, + isFetchingSuggestions, + ]); + + // the useEffect takes care of setting the dropdown values correctly on change of the current state + useEffect(() => { + if (currentState === DropdownState.ATTRIBUTE_KEY) { + if (isLogsExplorerPage) { + setDropdownOptions( + suggestionsData?.payload?.attributes?.map((key) => ({ + label: key.key, + value: key, + })) || [], + ); + } else { + setDropdownOptions( + data?.payload?.attributeKeys?.map((key) => ({ + label: key.key, + value: key, + })) || [], + ); + } + } + if (currentState === DropdownState.OPERATOR) { + const keyOperator = searchValue.split(' '); + const partialOperator = keyOperator?.[1]; + const strippedKey = keyOperator?.[0]; + + let operatorOptions; + if (currentFilterItem?.key?.dataType) { + operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[ + currentFilterItem.key + .dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES + ].map((operator) => ({ + label: operator, + value: operator, + })); + + if (partialOperator) { + operatorOptions = operatorOptions.filter((op) => + op.label.startsWith(partialOperator.toLocaleUpperCase()), + ); + } + setDropdownOptions(operatorOptions); + } else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) { + operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({ + label: operator, + value: operator, + })); + setDropdownOptions(operatorOptions); + } else { + operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map( + (operator) => ({ + label: operator, + value: operator, + }), + ); + + if (partialOperator) { + operatorOptions = operatorOptions.filter((op) => + op.label.startsWith(partialOperator.toLocaleUpperCase()), + ); + } + setDropdownOptions(operatorOptions); + } + } + + if (currentState === DropdownState.ATTRIBUTE_VALUE) { + const values: string[] = + Object.values(attributeValues?.payload || {}).find((el) => !!el) || []; + + const { tagValue } = getTagToken(searchValue); + + if (values.length === 0) { + if (isArray(tagValue)) { + if (!isEmpty(tagValue[tagValue.length - 1])) + values.push(tagValue[tagValue.length - 1]); + } else if (!isEmpty(tagValue)) values.push(tagValue); + } + + setDropdownOptions( + values.map((val) => ({ + label: val, + value: val, + })), + ); + } + }, [ + attributeValues?.payload, + currentFilterItem?.key.dataType, + currentState, + data?.payload?.attributeKeys, + isLogsExplorerPage, + searchValue, + suggestionsData?.payload?.attributes, + ]); + + useEffect(() => { + const filterTags: IBuilderQuery['filters'] = { + op: 'AND', + items: [], + }; + tags.forEach((tag) => { + filterTags.items.push({ + id: tag.id || uuid().slice(0, 8), + key: tag.key, + op: tag.op, + value: tag.value, + }); + }); + + if (!isEqual(query.filters, filterTags)) { + onChange(filterTags); + setTags(filterTags.items as ITag[]); + } + }, [onChange, query.filters, tags]); + + const isLastQuery = useMemo( + () => + isEqual( + currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1], + query, + ), + [currentQuery, query], + ); + + useEffect(() => { + if (isLastQuery) { + registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => { + // set timeout is needed here else the select treats the hotkey as input value + setTimeout(() => { + selectRef.current?.focus(); + }, 0); + }); + } + + return (): void => + deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar); + }, [deregisterShortcut, isLastQuery, registerShortcut]); + + const loading = useMemo( + () => isFetching || isFetchingAttributeValues || isFetchingSuggestions, + [isFetching, isFetchingAttributeValues, isFetchingSuggestions], + ); + + const isMetricsDataSource = useMemo( + () => query.dataSource === DataSource.METRICS, + [query.dataSource], + ); + + const queryTags = useMemo( + () => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`), + [tags], + ); + + const onTagRender = ({ + value, + closable, + onClose, + }: CustomTagProps): React.ReactElement => { + const { tagOperator } = getTagToken(value); + const isInNin = isInNInOperator(tagOperator); + const chipValue = isInNin + ? value?.trim()?.replace(/,\s*$/, '') + : value?.trim(); + + const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value)); + const tagDetails = tags[indexInQueryTags]; + + const onCloseHandler = (): void => { + onClose(); + setSearchValue(''); + setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); + }; + + const tagEditHandler = (value: string): void => { + setCurrentFilterItem(tagDetails); + setSearchValue(value); + setCurrentState(DropdownState.ATTRIBUTE_VALUE); + setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails))); + }; + + const isDisabled = !!searchValue; + + return ( + + + + { + if (!isDisabled) tagEditHandler(value); + }} + > + {chipValue} + + + + + ); + }; + + return ( +
+ +
+ ); +} + +QueryBuilderSearchV2.defaultProps = { + placeholder: PLACEHOLDER, + className: '', + suffixIcon: null, + whereClauseConfig: {}, +}; + +export default QueryBuilderSearchV2; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss new file mode 100644 index 0000000000..362d6e4c6a --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss @@ -0,0 +1,147 @@ +.text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: -0.07px; +} + +.dot { + height: 5px; + width: 5px; + border-radius: 50%; + background-color: var(--bg-slate-300); +} + +.option { + .container { + display: flex; + align-items: center; + justify-content: space-between; + + .left-section { + display: flex; + align-items: center; + width: 90%; + gap: 8px; + + .value { + } + } + .right-section { + display: flex; + align-items: center; + gap: 4px; + + .data-type { + display: flex; + height: 20px; + padding: 4px 8px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.08); + } + + .type-tag { + display: flex; + align-items: center; + height: 20px; + padding: 0px 6px; + justify-content: center; + gap: 4px; + border-radius: 50px; + text-transform: capitalize; + + &.tag { + border-radius: 50px; + background: rgba(189, 153, 121, 0.1) !important; + color: var(--bg-sienna-400) !important; + + .dot { + background-color: var(--bg-sienna-400); + } + .text { + color: var(--bg-sienna-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: -0.06px; + } + } + + &.resource { + border-radius: 50px; + background: rgba(245, 108, 135, 0.1) !important; + color: var(--bg-sakura-400) !important; + + .dot { + background-color: var(--bg-sakura-400); + } + .text { + color: var(--bg-sakura-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: -0.06px; + } + } + } + } + .option-meta-data-container { + display: flex; + gap: 8px; + } + } + + .container-without-tag { + display: flex; + align-items: center; + gap: 8px; + + .OPERATOR { + color: var(--bg-vanilla-400); + font-family: 'Space Mono'; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + text-transform: uppercase; + width: 100%; + } + + .VALUE { + color: var(--bg-vanilla-400); + font-family: 'Space Mono'; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + text-transform: uppercase; + width: 100%; + } + } +} +.option:hover { + .container { + .left-section { + .value { + color: var(--bg-vanilla-100); + } + } + } + .container-without-tag { + .value { + color: var(--bg-vanilla-100); + } + } +} diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx new file mode 100644 index 0000000000..49d6040b7b --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx @@ -0,0 +1,75 @@ +import './Suggestions.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Tooltip, Typography } from 'antd'; +import cx from 'classnames'; +import { isEmpty, isObject } from 'lodash-es'; +import { Zap } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; + +import { DropdownState } from './QueryBuilderSearchV2'; + +interface ISuggestionsProps { + label: string; + value: BaseAutocompleteData | string; + option: DropdownState; +} + +function Suggestions(props: ISuggestionsProps): React.ReactElement { + const { label, value, option } = props; + + const optionType = useMemo(() => { + if (isObject(value)) { + return value.type; + } + return ''; + }, [value]); + + const [truncated, setTruncated] = useState(false); + + return ( +
+ {!isEmpty(optionType) && isObject(value) ? ( + +
+
+ {value.isIndexed ? ( + + ) : ( +
+ )} + setTruncated(ellipsis) }} + > + {label} + +
+
+ {value.dataType} +
+
+ {value.type} +
+
+
+
+ ) : ( + +
+
+ setTruncated(ellipsis) }} + > + {`${label}`} + +
+ + )} +
+ ); +} + +export default Suggestions; diff --git a/frontend/src/hooks/queryBuilder/useGetAggregateValues.ts b/frontend/src/hooks/queryBuilder/useGetAggregateValues.ts new file mode 100644 index 0000000000..d749e5ec9a --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useGetAggregateValues.ts @@ -0,0 +1,33 @@ +import { getAttributesValues } from 'api/queryBuilder/getAttributesValues'; +import { useMemo } from 'react'; +import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + IAttributeValuesResponse, + IGetAttributeValuesPayload, +} from 'types/api/queryBuilder/getAttributesValues'; + +type UseGetAttributeValues = ( + requestData: IGetAttributeValuesPayload, + options?: UseQueryOptions< + SuccessResponse | ErrorResponse + >, +) => UseQueryResult | ErrorResponse>; + +export const useGetAggregateValues: UseGetAttributeValues = ( + requestData, + options, +) => { + const queryKey = useMemo(() => { + if (options?.queryKey && Array.isArray(options.queryKey)) { + return [...options.queryKey]; + } + return [requestData]; + }, [options?.queryKey, requestData]); + + return useQuery | ErrorResponse>({ + queryKey, + queryFn: () => getAttributesValues(requestData), + ...options, + }); +}; diff --git a/frontend/src/hooks/queryBuilder/useIsValidTag.ts b/frontend/src/hooks/queryBuilder/useIsValidTag.ts index 216971b0ac..d4e2b58080 100644 --- a/frontend/src/hooks/queryBuilder/useIsValidTag.ts +++ b/frontend/src/hooks/queryBuilder/useIsValidTag.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { OperatorType } from './useOperatorType'; -const validationMapper: Record< +export const validationMapper: Record< OperatorType, (resultLength: number) => boolean > = { diff --git a/frontend/src/hooks/queryBuilder/useOperatorType.ts b/frontend/src/hooks/queryBuilder/useOperatorType.ts index 94de55df92..1ff0619506 100644 --- a/frontend/src/hooks/queryBuilder/useOperatorType.ts +++ b/frontend/src/hooks/queryBuilder/useOperatorType.ts @@ -6,7 +6,7 @@ export type OperatorType = | 'NON_VALUE' | 'NOT_VALID'; -const operatorTypeMapper: Record = { +export const operatorTypeMapper: Record = { [OPERATORS.IN]: 'MULTIPLY_VALUE', [OPERATORS.NIN]: 'MULTIPLY_VALUE', [OPERATORS.EXISTS]: 'NON_VALUE',