From 6b096576eec07b2a1699e478bf2a98c2344bd129 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Fri, 30 Aug 2024 17:50:28 +0530 Subject: [PATCH] feat: consume the new search bar (#5728) * feat: consume the new search bar * fix: minor css issue * chore: address review comments * fix: very fast typing * chore: added inline code comments * chore: add the changes behind FF --- ee/query-service/model/plans.go | 22 ++++ frontend/src/constants/features.ts | 1 + .../LogExplorerQuerySection/index.tsx | 24 ++++- .../QueryBuilderSearchV2.tsx | 101 +++++++++++++----- .../Suggestions.styles.scss | 63 +++++++---- .../QueryBuilderSearchV2/Suggestions.tsx | 37 +++++-- 6 files changed, 187 insertions(+), 61 deletions(-) diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 9b696c013f..dbd8b56965 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -13,6 +13,7 @@ const Onboarding = "ONBOARDING" const ChatSupport = "CHAT_SUPPORT" const Gateway = "GATEWAY" const PremiumSupport = "PREMIUM_SUPPORT" +const QueryBuilderSearchV2 = "QUERY_BUILDER_SEARCH_V2" var BasicPlan = basemodel.FeatureSet{ basemodel.Feature{ @@ -127,6 +128,13 @@ var BasicPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } var ProPlan = basemodel.FeatureSet{ @@ -235,6 +243,13 @@ var ProPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } var EnterprisePlan = basemodel.FeatureSet{ @@ -357,4 +372,11 @@ var EnterprisePlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: QueryBuilderSearchV2, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, } diff --git a/frontend/src/constants/features.ts b/frontend/src/constants/features.ts index bdacdb057b..769522455d 100644 --- a/frontend/src/constants/features.ts +++ b/frontend/src/constants/features.ts @@ -21,4 +21,5 @@ export enum FeatureKeys { CHAT_SUPPORT = 'CHAT_SUPPORT', GATEWAY = 'GATEWAY', PREMIUM_SUPPORT = 'PREMIUM_SUPPORT', + QUERY_BUILDER_SEARCH_V2 = 'QUERY_BUILDER_SEARCH_V2', } diff --git a/frontend/src/container/LogExplorerQuerySection/index.tsx b/frontend/src/container/LogExplorerQuerySection/index.tsx index 1eea60da47..f807103f68 100644 --- a/frontend/src/container/LogExplorerQuerySection/index.tsx +++ b/frontend/src/container/LogExplorerQuerySection/index.tsx @@ -1,5 +1,6 @@ import './LogsExplorerQuerySection.styles.scss'; +import { FeatureKeys } from 'constants/features'; import { initialQueriesMap, OPERATORS, @@ -9,11 +10,13 @@ import ExplorerOrderBy from 'container/ExplorerOrderBy'; import { QueryBuilder } from 'container/QueryBuilder'; import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces'; import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; +import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2'; import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces'; import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; +import useFeatureFlags from 'hooks/useFeatureFlag'; import { prepareQueryWithDefaultTimestamp, SELECTED_VIEWS, @@ -86,15 +89,26 @@ function LogExplorerQuerySection({ [handleChangeQueryData], ); + const isSearchV2Enabled = + useFeatureFlags(FeatureKeys.QUERY_BUILDER_SEARCH_V2)?.active || false; + return ( <> {selectedView === SELECTED_VIEWS.SEARCH && (
- + {isSearchV2Enabled ? ( + + ) : ( + + )}
)} diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index d53a517c48..4c800e7d6d 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -56,7 +56,11 @@ 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 { + checkCommaInValue, + getTagToken, + isInNInOperator, +} from '../QueryBuilderSearch/utils'; import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown'; import Suggestions from './Suggestions'; @@ -213,18 +217,11 @@ function QueryBuilderSearchV2( const isQueryEnabled = useMemo(() => { if (currentState === DropdownState.ATTRIBUTE_KEY) { return query.dataSource === DataSource.METRICS - ? !!query.aggregateOperator && - !!query.dataSource && - !!query.aggregateAttribute.dataType + ? !!query.dataSource && !!query.aggregateAttribute.dataType : true; } return false; - }, [ - currentState, - query.aggregateAttribute.dataType, - query.aggregateOperator, - query.dataSource, - ]); + }, [currentState, query.aggregateAttribute.dataType, query.dataSource]); const { data, isFetching } = useGetAggregateKeys( { @@ -324,6 +321,23 @@ function QueryBuilderSearchV2( if (isMulti) { const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); + // this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value. + // not the best interaction but in sync with what we have today! + if (tagValue.includes(String(value))) { + setSearchValue(''); + setCurrentState(DropdownState.ATTRIBUTE_KEY); + setCurrentFilterItem(undefined); + setTags((prev) => [ + ...prev, + { + key: currentFilterItem?.key, + op: currentFilterItem?.op, + value: tagValue.join(','), + } as ITag, + ]); + return; + } + // this is for adding subsequent comma seperated values const newSearch = [...tagValue]; newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value; const newSearchValue = newSearch.join(','); @@ -356,6 +370,7 @@ function QueryBuilderSearchV2( event.stopPropagation(); setTags((prev) => prev.slice(0, -1)); } + if ((event.ctrlKey || event.metaKey) && event.key === '/') { event.preventDefault(); event.stopPropagation(); @@ -375,6 +390,7 @@ function QueryBuilderSearchV2( if (searchValue) { const operatorType = operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID'; + // if key is added and operator is not present then convert to body CONTAINS key if ( currentFilterItem?.key && isEmpty(currentFilterItem?.op) && @@ -403,6 +419,7 @@ function QueryBuilderSearchV2( currentFilterItem?.op === OPERATORS.EXISTS || currentFilterItem?.op === OPERATORS.NOT_EXISTS ) { + // is exists and not exists operator is present then convert directly to tag! no need of value here setTags((prev) => [ ...prev, { @@ -415,6 +432,7 @@ function QueryBuilderSearchV2( setSearchValue(''); setCurrentState(DropdownState.ATTRIBUTE_KEY); } else if ( + // if the current state is in sync with the kind of operator used then convert into a tag validationMapper[operatorType]?.( isArray(currentFilterItem?.value) ? currentFilterItem?.value.length || 0 @@ -445,15 +463,21 @@ function QueryBuilderSearchV2( // this useEffect takes care of tokenisation based on the search state useEffect(() => { + // if we are still fetching the suggestions then return as we won't know the type / data-type etc for the attribute key if (isFetchingSuggestions) { return; } + + // if there is no search value reset to the default state if (!searchValue) { setCurrentFilterItem(undefined); setCurrentState(DropdownState.ATTRIBUTE_KEY); } + + // split the current search value based on delimiters const { tagKey, tagOperator, tagValue } = getTagToken(searchValue); + // Case 1 -> when typing an attribute key (not selecting from dropdown) if (tagKey && isUndefined(currentFilterItem?.key)) { let currentRunningAttributeKey; const isSuggestedKeyInAutocomplete = suggestionsData?.payload?.attributes?.some( @@ -470,8 +494,17 @@ function QueryBuilderSearchV2( [currentRunningAttributeKey] = allAttributesMatchingTheKey; } if (allAttributesMatchingTheKey?.length > 1) { - // the priority logic goes here - [currentRunningAttributeKey] = allAttributesMatchingTheKey; + // when there are multiple options let the user choose it until they do not select an operator + if (tagOperator) { + // if they select the operator then pick the first one from the ranked list + setCurrentFilterItem({ + key: allAttributesMatchingTheKey?.[0], + op: tagOperator, + value: '', + }); + setCurrentState(DropdownState.ATTRIBUTE_VALUE); + } + return; } if (currentRunningAttributeKey) { @@ -488,7 +521,6 @@ function QueryBuilderSearchV2( 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, @@ -500,12 +532,15 @@ function QueryBuilderSearchV2( setCurrentState(DropdownState.OPERATOR); } } else if ( + // Case 2 - if key is defined but the search text doesn't match with the set key, + // can happen when user selects from dropdown and then deletes a few characters currentFilterItem?.key && currentFilterItem?.key?.key !== tagKey.split(' ')[0] ) { setCurrentFilterItem(undefined); setCurrentState(DropdownState.ATTRIBUTE_KEY); } else if (tagOperator && isEmpty(currentFilterItem?.op)) { + // Case 3 -> key is set and now typing for the operator if ( tagOperator === OPERATORS.EXISTS || tagOperator === OPERATORS.NOT_EXISTS @@ -531,6 +566,7 @@ function QueryBuilderSearchV2( setCurrentState(DropdownState.ATTRIBUTE_VALUE); } } else if ( + // Case 4 -> selected operator from dropdown and then erased a part of it !isEmpty(currentFilterItem?.op) && tagOperator !== currentFilterItem?.op ) { @@ -540,10 +576,12 @@ function QueryBuilderSearchV2( value: '', })); setCurrentState(DropdownState.OPERATOR); - } else if (!isEmpty(tagValue)) { + } else if (currentState === DropdownState.ATTRIBUTE_VALUE) { + // Case 5 -> the final value state where we set the current filter values and the tokenisation happens on either + // dropdown click or blur event const currentValue = { key: currentFilterItem?.key as BaseAutocompleteData, - operator: currentFilterItem?.op as string, + op: currentFilterItem?.op as string, value: tagValue, }; if (!isEqual(currentValue, currentFilterItem)) { @@ -561,6 +599,7 @@ function QueryBuilderSearchV2( suggestionsData?.payload?.attributes, searchValue, isFetchingSuggestions, + currentState, ]); // the useEffect takes care of setting the dropdown values correctly on change of the current state @@ -627,28 +666,27 @@ function QueryBuilderSearchV2( } if (currentState === DropdownState.ATTRIBUTE_VALUE) { - const values: string[] = - Object.values(attributeValues?.payload || {}).find((el) => !!el) || []; - + const values: string[] = []; const { tagValue } = getTagToken(searchValue); + if (isArray(tagValue)) { + if (!isEmpty(tagValue[tagValue.length - 1])) + values.push(tagValue[tagValue.length - 1]); + } else if (!isEmpty(tagValue)) values.push(tagValue); - 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); - } + values.push( + ...(Object.values(attributeValues?.payload || {}).find((el) => !!el) || []), + ); setDropdownOptions( values.map((val) => ({ - label: val, + label: checkCommaInValue(String(val)), value: val, })), ); } }, [ attributeValues?.payload, - currentFilterItem?.key.dataType, + currentFilterItem?.key?.dataType, currentState, data?.payload?.attributeKeys, isLogsExplorerPage, @@ -674,7 +712,15 @@ function QueryBuilderSearchV2( onChange(filterTags); setTags(filterTags.items as ITag[]); } - }, [onChange, query.filters, tags]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tags]); + + useEffect(() => { + if (!isEqual(query.filters.items, tags)) { + setTags(getInitTags(query)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]); const isLastQuery = useMemo( () => @@ -843,6 +889,7 @@ function QueryBuilderSearchV2( label={option.label} value={option.value} option={currentState} + searchValue={searchValue} /> ); diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss index 362d6e4c6a..bd7ad36a5a 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.styles.scss @@ -105,29 +105,52 @@ display: flex; align-items: center; gap: 8px; + justify-content: space-between; - .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%; + .left { + 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%; + } } - .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%; + .right { + 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); + } } } } diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx index 49d6040b7b..88507d80eb 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions.tsx @@ -4,20 +4,22 @@ 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 { Check, Zap } from 'lucide-react'; import { useMemo, useState } from 'react'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { getTagToken } from '../QueryBuilderSearch/utils'; import { DropdownState } from './QueryBuilderSearchV2'; interface ISuggestionsProps { label: string; value: BaseAutocompleteData | string; option: DropdownState; + searchValue: string; } function Suggestions(props: ISuggestionsProps): React.ReactElement { - const { label, value, option } = props; + const { label, value, option, searchValue } = props; const optionType = useMemo(() => { if (isObject(value)) { @@ -26,6 +28,15 @@ function Suggestions(props: ISuggestionsProps): React.ReactElement { return ''; }, [value]); + const dataType = useMemo(() => { + if (isObject(value)) { + return value.dataType; + } + return ''; + }, [value]); + + const { tagValue } = getTagToken(searchValue); + const [truncated, setTruncated] = useState(false); return ( @@ -58,13 +69,21 @@ function Suggestions(props: ISuggestionsProps): React.ReactElement { ) : (
-
- setTruncated(ellipsis) }} - > - {`${label}`} - +
+
+ setTruncated(ellipsis) }} + > + {`${label}`} + +
+
+ {dataType && ( + {dataType} + )} + {tagValue.includes(label) && } +
)}