feat: rewrite the query builder search component (#5659)

* feat: make the query builder search extensible

* feat: setup the framework and necessary states needed

* feat: cover the happy path of selects

* chore: forward typing flow handled

* chore: add antd select

* chore: add antd select

* chore: handle forward and backward flows

* feat: added tag properites to the search bar and multi tag partial handling

* feat: handle tag on blur and body contains changes

* feat: handle tag deselect

* feat: multi tag handling

* feat: multi tag handling

* fix: jest test cases

* chore: update the key

* chore: add edit tag not working as expected

* feat: handle cases for exists and nexists

* fix: handle has / nhas operators

* chore: fix usability issues

* chore: remove the usage for the new bar

* fix: flaky build issues

* feat: client changes for consumption and design changes for where clause in logs explorer  (#5712)

* feat: query search new ui

* feat: suggestions changes in v2

* feat: dropdown and tags ui touch up

* feat: added missing keyboard shortcuts

* fix: race condition issues

* feat: remove usage

* fix: operator select fix

* fix: handle example queries click changes

* chore: design sync

* chore: handle boolean selects

* chore: address review comments
This commit is contained in:
Vikrant Gupta 2024-08-20 17:09:17 +05:30 committed by GitHub
parent 98367fd054
commit 5796d6cb8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1494 additions and 3 deletions

View File

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

View File

@ -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 (
<>
<div className="content">
{!currentFilterItem?.key ? (
<div className="suggested-filters">Suggested Filters</div>
) : !currentFilterItem?.op ? (
<div className="operator-for">
<Typography.Text className="operator-for-text">
Operator for{' '}
</Typography.Text>
<Typography.Text className="operator-for-value">
{currentFilterItem?.key?.key}
</Typography.Text>
</div>
) : (
<div className="value-for">
<Typography.Text className="value-for-text">
Value(s) for{' '}
</Typography.Text>
<Typography.Text className="value-for-value">
{currentFilterItem?.key?.key} {currentFilterItem?.op}
</Typography.Text>
</div>
)}
{menu}
{!searchValue && tags.length === 0 && (
<div className="example-queries">
<div className="heading"> Example Queries </div>
<div className="query-container">
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
<ExampleQueriesRendererForLogs
key={query.label}
label={query.label}
value={query.value}
handleAddTag={onChange}
/>
))}
</div>
</div>
)}
</div>
<div className="keyboard-shortcuts">
<section className="navigate">
<ArrowDown size={10} className="icons" />
<ArrowUp size={10} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
<section className="update-query">
<CornerDownLeft size={10} className="icons" />
<span className="keyboard-text">to update query</span>
</section>
{!currentFilterItem?.key && options.length > 3 && (
<section className="show-all-filter-items">
{userOs === UserOperatingSystem.MACOS ? (
<Command size={14} className="icons" />
) : (
<ChevronUp size={14} className="icons" />
)}
+
<Slash size={14} className="icons" />
<span className="keyboard-text">Show all filter items</span>
</section>
)}
</div>
</>
);
}
QueryBuilderSearchDropdown.defaultProps = {
currentFilterItem: undefined,
};

View File

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

View File

@ -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<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(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<ITag[]>(() => getInitTags(query));
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const [showAllFilters, setShowAllFilters] = useState<boolean>(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<Element>): 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 (
<span className="qb-search-bar-tokenised-tags">
<Tag
closable={!searchValue && closable}
onClose={onCloseHandler}
className={tagDetails?.key?.type || ''}
>
<Tooltip title={chipValue}>
<TypographyText
ellipsis
$isInNin={isInNin}
disabled={isDisabled}
$isEnabled={!!searchValue}
onClick={(): void => {
if (!isDisabled) tagEditHandler(value);
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Tag>
</span>
);
};
return (
<div className="query-builder-search-v2">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={
// eslint-disable-next-line no-nested-ternary
!isUndefined(suffixIcon) ? (
suffixIcon
) : isOpen ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={cx(
!currentFilterItem?.key && !showAllFilters && dropdownOptions.length > 3
? 'show-all-filters'
: '',
className,
)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={loading ? <Spin size="small" /> : null}
showAction={['focus']}
onBlur={handleOnBlur}
// eslint-disable-next-line react/no-unstable-nested-components
dropdownRender={(menu): ReactElement => (
<QueryBuilderSearchDropdown
menu={menu}
options={dropdownOptions}
onChange={(val: TagFilter): void => {
setTags((prev) => [...prev, ...(val.items as ITag[])]);
}}
searchValue={searchValue}
exampleQueries={suggestionsData?.payload?.example_queries || []}
tags={tags}
currentFilterItem={currentFilterItem}
/>
)}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
QueryBuilderSearchV2.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
};
export default QueryBuilderSearchV2;

View File

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

View File

@ -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<boolean>(false);
return (
<div className="option">
{!isEmpty(optionType) && isObject(value) ? (
<Tooltip title={truncated ? `${value.key}` : ''} placement="topLeft">
<div className="container">
<section className="left-section">
{value.isIndexed ? (
<Zap size={12} fill={Color.BG_AMBER_500} />
) : (
<div className="dot" />
)}
<Typography.Text
className="text value"
ellipsis={{ onEllipsis: (ellipsis): void => setTruncated(ellipsis) }}
>
{label}
</Typography.Text>
</section>
<section className="right-section">
<Typography.Text className="data-type">{value.dataType}</Typography.Text>
<section className={cx('type-tag', value.type)}>
<div className="dot" />
<Typography.Text className="text">{value.type}</Typography.Text>
</section>
</section>
</div>
</Tooltip>
) : (
<Tooltip title={truncated ? label : ''} placement="topLeft">
<div className="container-without-tag">
<div className="dot" />
<Typography.Text
className={cx('text value', option)}
ellipsis={{ onEllipsis: (ellipsis): void => setTruncated(ellipsis) }}
>
{`${label}`}
</Typography.Text>
</div>
</Tooltip>
)}
</div>
);
}
export default Suggestions;

View File

@ -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<IAttributeValuesResponse> | ErrorResponse
>,
) => UseQueryResult<SuccessResponse<IAttributeValuesResponse> | 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<SuccessResponse<IAttributeValuesResponse> | ErrorResponse>({
queryKey,
queryFn: () => getAttributesValues(requestData),
...options,
});
};

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { OperatorType } from './useOperatorType';
const validationMapper: Record<
export const validationMapper: Record<
OperatorType,
(resultLength: number) => boolean
> = {

View File

@ -6,7 +6,7 @@ export type OperatorType =
| 'NON_VALUE'
| 'NOT_VALID';
const operatorTypeMapper: Record<string, OperatorType> = {
export const operatorTypeMapper: Record<string, OperatorType> = {
[OPERATORS.IN]: 'MULTIPLY_VALUE',
[OPERATORS.NIN]: 'MULTIPLY_VALUE',
[OPERATORS.EXISTS]: 'NON_VALUE',