diff --git a/frontend/src/api/queryBuilder/getAttributesKeysValues.ts b/frontend/src/api/queryBuilder/getAttributesKeysValues.ts new file mode 100644 index 0000000000..f5b938a345 --- /dev/null +++ b/frontend/src/api/queryBuilder/getAttributesKeysValues.ts @@ -0,0 +1,63 @@ +import { ApiV3Instance } from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +export type TagKeyValueProps = { + dataSource: string; + aggregateOperator?: string; + aggregateAttribute?: string; + searchText?: string; + attributeKey?: string; +}; + +export interface AttributeKeyOptions { + key: string; + type: string; + dataType: 'string' | 'boolean' | 'number'; + isColumn: boolean; +} + +export const getAttributesKeys = async ( + props: TagKeyValueProps, +): Promise | ErrorResponse> => { + try { + const response = await ApiV3Instance.get( + `/autocomplete/attribute_keys?aggregateOperator=${props.aggregateOperator}&dataSource=${props.dataSource}&aggregateAttribute=${props.aggregateAttribute}&searchText=${props.searchText}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.attributeKeys, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export interface TagValuePayloadProps { + boolAttributeValues: null | string[]; + numberAttributeValues: null | string[]; + stringAttributeValues: null | string[]; +} + +export const getAttributesValues = async ( + props: TagKeyValueProps, +): Promise | ErrorResponse> => { + try { + const response = await ApiV3Instance.get( + `/autocomplete/attribute_values?aggregateOperator=${props.aggregateOperator}&dataSource=${props.dataSource}&aggregateAttribute=${props.aggregateAttribute}&searchText=${props.searchText}&attributeKey=${props.attributeKey}`, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; diff --git a/frontend/src/constants/queryBuilder.ts b/frontend/src/constants/queryBuilder.ts index 5b881b4b27..ba174d8137 100644 --- a/frontend/src/constants/queryBuilder.ts +++ b/frontend/src/constants/queryBuilder.ts @@ -61,3 +61,78 @@ export const operatorsByTypes: Record = { number: Object.values(NumberOperators), bool: Object.values(BoolOperators), }; + +export type IQueryBuilderState = 'search'; + +export const QUERY_BUILDER_SEARCH_VALUES = { + MULTIPLY: 'MULTIPLY_VALUE', + SINGLE: 'SINGLE_VALUE', + NON: 'NON_VALUE', + NOT_VALID: 'NOT_VALID', +}; + +export const OPERATORS = { + IN: 'IN', + NIN: 'NOT_IN', + LIKE: 'LIKE', + NLIKE: 'NOT_LIKE', + EQUALS: '=', + NOT_EQUALS: '!=', + EXISTS: 'EXISTS', + NOT_EXISTS: 'NOT_EXISTS', + CONTAINS: 'CONTAINS', + NOT_CONTAINS: 'NOT_CONTAINS', + GTE: '>=', + GT: '>', + LTE: '<=', + LT: '<', +}; + +export const QUERY_BUILDER_OPERATORS_BY_TYPES = { + string: [ + OPERATORS.EQUALS, + OPERATORS.NOT_EQUALS, + OPERATORS.IN, + OPERATORS.NIN, + OPERATORS.LIKE, + OPERATORS.NLIKE, + OPERATORS.CONTAINS, + OPERATORS.NOT_CONTAINS, + OPERATORS.EXISTS, + OPERATORS.NOT_EXISTS, + ], + number: [ + OPERATORS.EQUALS, + OPERATORS.NOT_EQUALS, + OPERATORS.IN, + OPERATORS.NIN, + OPERATORS.EXISTS, + OPERATORS.NOT_EXISTS, + OPERATORS.GTE, + OPERATORS.GT, + OPERATORS.LTE, + OPERATORS.LT, + ], + boolean: [ + OPERATORS.EQUALS, + OPERATORS.NOT_EQUALS, + OPERATORS.EXISTS, + OPERATORS.NOT_EXISTS, + ], + universal: [ + OPERATORS.EQUALS, + OPERATORS.NOT_EQUALS, + OPERATORS.IN, + OPERATORS.NIN, + OPERATORS.EXISTS, + OPERATORS.NOT_EXISTS, + OPERATORS.LIKE, + OPERATORS.NLIKE, + OPERATORS.GTE, + OPERATORS.GT, + OPERATORS.LTE, + OPERATORS.LT, + OPERATORS.CONTAINS, + OPERATORS.NOT_CONTAINS, + ], +}; diff --git a/frontend/src/container/NewWidget/styles.ts b/frontend/src/container/NewWidget/styles.ts index 386f044a0b..c9941ad07c 100644 --- a/frontend/src/container/NewWidget/styles.ts +++ b/frontend/src/container/NewWidget/styles.ts @@ -11,12 +11,14 @@ export const Container = styled.div` export const RightContainerWrapper = styled(Col)` &&& { min-width: 200px; + margin-bottom: 1rem; } `; export const LeftContainerWrapper = styled(Col)` &&& { margin-right: 1rem; + margin-bottom: 1rem; max-width: 70%; } `; diff --git a/frontend/src/container/QueryBuilder/components/ListMarker/ListMarker.styled.ts b/frontend/src/container/QueryBuilder/components/ListMarker/ListMarker.styled.ts index 5c18783959..5c1e5cc396 100644 --- a/frontend/src/container/QueryBuilder/components/ListMarker/ListMarker.styled.ts +++ b/frontend/src/container/QueryBuilder/components/ListMarker/ListMarker.styled.ts @@ -7,7 +7,7 @@ export const StyledButton = styled(Button)<{ $isAvailableToDisable: boolean }>` padding: ${(props): string => props.$isAvailableToDisable ? '0.43rem' : '0.43rem 0.68rem'}; border-radius: 0.375rem; - margin-right: 0.1rem; + margin-right: 0.5rem; pointer-events: ${(props): string => props.$isAvailableToDisable ? 'default' : 'none'}; `; diff --git a/frontend/src/container/QueryBuilder/components/Query/Query.tsx b/frontend/src/container/QueryBuilder/components/Query/Query.tsx index 4af3906699..a5c27c6a6d 100644 --- a/frontend/src/container/QueryBuilder/components/Query/Query.tsx +++ b/frontend/src/container/QueryBuilder/components/Query/Query.tsx @@ -2,10 +2,10 @@ import { Col, Input, Row } from 'antd'; // ** Constants import { initialAggregateAttribute, + initialQueryBuilderFormValues, mapOfFilters, mapOfOperators, } from 'constants/queryBuilder'; -import { initialQueryBuilderFormValues } from 'constants/queryBuilder'; // ** Components import { AdditionalFiltersToggler, @@ -19,7 +19,7 @@ import { OperatorsSelect, ReduceToFilter, } from 'container/QueryBuilder/filters'; -// Context +import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; import { useQueryBuilder } from 'hooks/useQueryBuilder'; import { findDataTypeOfOperator } from 'lib/query/findDataTypeOfOperator'; // ** Hooks @@ -186,12 +186,17 @@ export const Query = memo(function Query({ removeEntityByIndex('queryData', index); }, [removeEntityByIndex, index]); + const isMatricsDataSource = useMemo( + () => query.dataSource === DataSource.METRICS, + [query.dataSource], + ); + return ( - - + + ) : ( )} - {/* TODO: here will be search */} + {isMatricsDataSource && } + + + diff --git a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx index 174967b8f7..49945bf905 100644 --- a/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx +++ b/frontend/src/container/QueryBuilder/filters/AggregatorFilter/AggregatorFilter.tsx @@ -9,6 +9,7 @@ import { useQuery } from 'react-query'; import { SelectOption } from 'types/common/select'; import { transformToUpperCase } from 'utils/transformToUpperCase'; +import { selectStyle } from '../QueryBuilderSearch/config'; // ** Types import { AgregatorFilterProps } from './AggregatorFilter.intefaces'; @@ -27,16 +28,15 @@ export const AggregatorFilter = memo(function AggregatorFilter({ ], async () => getAggregateAttribute({ + searchText, aggregateOperator: query.aggregateOperator, dataSource: query.dataSource, - searchText, }), { enabled: !!query.aggregateOperator && !!query.dataSource }, ); - const handleSearchAttribute = (searchText: string): void => { + const handleSearchAttribute = (searchText: string): void => setSearchText(searchText); - }; const optionsData: SelectOption[] = data?.payload?.attributeKeys?.map((item) => ({ @@ -70,7 +70,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({ { + const isInNin = isInNotInOperator(value); + + const onCloseHandler = (): void => { + onClose(); + handleSearch(''); + }; + + return ( + + + + {value} + + + + ); + }; + + const onChangeHandler = (value: string[]): void => { + if (!isMulti) handleSearch(value[value.length - 1]); + }; + + const onInputKeyDownHandler = (event: React.KeyboardEvent): void => { + if (isMulti || event.key === 'Backspace') handleKeyDown(event); + }; + + return ( + + ); +} + +interface QueryBuilderSearchProps { + query: IBuilderQueryForm; +} + +export interface CustomTagProps { + label: React.ReactNode; + value: string; + disabled: boolean; + onClose: (event?: React.MouseEvent) => void; + closable: boolean; +} + +export default QueryBuilderSearch; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts new file mode 100644 index 0000000000..064392b773 --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/style.ts @@ -0,0 +1,11 @@ +import { CheckOutlined } from '@ant-design/icons'; +import { Typography } from 'antd'; +import styled from 'styled-components'; + +export const TypographyText = styled(Typography.Text)<{ isInNin: boolean }>` + width: ${({ isInNin }): string => (isInNin ? '10rem' : 'auto')}; +`; + +export const StyledCheckOutlined = styled(CheckOutlined)` + float: right; +`; diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts new file mode 100644 index 0000000000..7ff23055eb --- /dev/null +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearch/utils.ts @@ -0,0 +1,9 @@ +import { OPERATORS } from 'constants/queryBuilder'; + +export function isInNotInOperator(value: string): boolean { + return value?.includes(OPERATORS.IN || OPERATORS.NIN); +} + +export function isExistsNotExistsOperator(value: string): boolean { + return value?.includes(OPERATORS.EXISTS || OPERATORS.NOT_EXISTS); +} diff --git a/frontend/src/container/QueryBuilder/type.ts b/frontend/src/container/QueryBuilder/type.ts new file mode 100644 index 0000000000..85cfe42e84 --- /dev/null +++ b/frontend/src/container/QueryBuilder/type.ts @@ -0,0 +1,16 @@ +import { IQueryBuilderState } from 'constants/queryBuilder'; + +export interface InitialStateI { + search: string; +} + +export interface ContextValueI { + values: InitialStateI; + onChangeHandler: (type: IQueryBuilderState) => (value: string) => void; + onSubmitHandler: VoidFunction; +} + +export type Option = { + value: string; + selected?: boolean; +}; diff --git a/frontend/src/hooks/queryBuilder/useAutoComplete.ts b/frontend/src/hooks/queryBuilder/useAutoComplete.ts new file mode 100644 index 0000000000..49ce84e109 --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useAutoComplete.ts @@ -0,0 +1,131 @@ +import { isExistsNotExistsOperator } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { Option } from 'container/QueryBuilder/type'; +import { useCallback, useState } from 'react'; +import { IBuilderQueryForm } from 'types/api/queryBuilder/queryBuilderData'; +import { checkStringEndsWithSpace } from 'utils/checkStringEndsWithSpace'; + +import { useFetchKeysAndValues } from './useFetchKeysAndValues'; +import { useOptions } from './useOptions'; +import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator'; +import { useTag } from './useTag'; +import { useTagValidation } from './useTagValidation'; + +interface IAutoComplete { + handleSearch: (value: string) => void; + handleClearTag: (value: string) => void; + handleSelect: (value: string) => void; + handleKeyDown: (event: React.KeyboardEvent) => void; + options: Option[]; + tags: string[]; + searchValue: string; + isMulti: boolean; + isFetching: boolean; +} + +export const useAutoComplete = (query: IBuilderQueryForm): IAutoComplete => { + const [searchValue, setSearchValue] = useState(''); + + const handleSearch = (value: string): void => setSearchValue(value); + + const { keys, results, isFetching } = useFetchKeysAndValues( + searchValue, + query, + ); + + const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys); + + const { + isValidTag, + isExist, + isValidOperator, + isMulti, + isFreeText, + } = useTagValidation(searchValue, operator, result); + + const { handleAddTag, handleClearTag, tags } = useTag( + key, + isValidTag, + isFreeText, + handleSearch, + ); + + const handleSelect = useCallback( + (value: string): void => { + if (isMulti) { + setSearchValue((prev: string) => { + if (prev.includes(value)) { + return prev.replace(` ${value}`, ''); + } + return checkStringEndsWithSpace(prev) + ? `${prev} ${value}` + : `${prev}, ${value}`; + }); + } + if (!isMulti && isValidTag && !isExistsNotExistsOperator(value)) { + handleAddTag(value); + } + if (!isMulti && isExistsNotExistsOperator(value)) { + handleAddTag(value); + } + }, + [handleAddTag, isMulti, isValidTag], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if ( + event.key === ' ' && + (searchValue.endsWith(' ') || searchValue.length === 0) + ) { + event.preventDefault(); + } + + if (event.key === 'Enter' && searchValue && isValidTag) { + if (isMulti || isFreeText) { + event.stopPropagation(); + } + event.preventDefault(); + handleAddTag(searchValue); + } + + if (event.key === 'Backspace' && !searchValue) { + event.stopPropagation(); + const last = tags[tags.length - 1]; + handleClearTag(last); + } + }, + [ + handleAddTag, + handleClearTag, + isFreeText, + isMulti, + isValidTag, + searchValue, + tags, + ], + ); + + const options = useOptions( + key, + keys, + operator, + searchValue, + isMulti, + isValidOperator, + isExist, + results, + result, + ); + + return { + handleSearch, + handleClearTag, + handleSelect, + handleKeyDown, + options, + tags, + searchValue, + isMulti, + isFetching, + }; +}; diff --git a/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts b/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts new file mode 100644 index 0000000000..3fd04181dc --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useFetchKeysAndValues.ts @@ -0,0 +1,106 @@ +import { + AttributeKeyOptions, + getAttributesKeys, + getAttributesValues, +} from 'api/queryBuilder/getAttributesKeysValues'; +import { useEffect, useRef, useState } from 'react'; +import { useQuery } from 'react-query'; +import { useDebounce } from 'react-use'; +import { IBuilderQueryForm } from 'types/api/queryBuilder/queryBuilderData'; +import { separateSearchValue } from 'utils/separateSearchValue'; + +type UseFetchKeysAndValuesReturnValues = { + keys: AttributeKeyOptions[]; + results: string[]; + isFetching: boolean; +}; + +/** + * Custom hook to fetch attribute keys and values from an API + * @param searchValue - the search query value + * @param query - an object containing data for the query + * @returns an object containing the fetched attribute keys, results, and the status of the fetch + */ + +export const useFetchKeysAndValues = ( + searchValue: string, + query: IBuilderQueryForm, +): UseFetchKeysAndValuesReturnValues => { + const [keys, setKeys] = useState([]); + const [results, setResults] = useState([]); + const { data, isFetching, status } = useQuery( + [ + 'GET_ATTRIBUTE_KEY', + searchValue, + query.dataSource, + query.aggregateOperator, + query.aggregateAttribute.key, + ], + async () => + getAttributesKeys({ + searchText: searchValue, + dataSource: query.dataSource, + aggregateOperator: query.aggregateOperator, + aggregateAttribute: query.aggregateAttribute.key, + }), + { enabled: !!query.aggregateOperator && !!query.dataSource }, + ); + + /** + * Fetches the options to be displayed based on the selected value + * @param value - the selected value + * @param query - an object containing data for the query + */ + const handleFetchOption = async ( + value: string, + query: IBuilderQueryForm, + ): Promise => { + if (value) { + // separate the search value into the attribute key and the operator + const [tKey, operator] = separateSearchValue(value); + setResults([]); + if (tKey && operator) { + const { payload } = await getAttributesValues({ + searchText: searchValue, + dataSource: query.dataSource, + aggregateOperator: query.aggregateOperator, + aggregateAttribute: query.aggregateAttribute.key, + attributeKey: tKey, + }); + if (payload) { + const values = Object.values(payload).find((el) => !!el); + if (values) { + setResults(values); + } else { + setResults([]); + } + } + } + } + }; + + // creates a ref to the fetch function so that it doesn't change on every render + const clearFetcher = useRef(handleFetchOption).current; + + // debounces the fetch function to avoid excessive API calls + useDebounce(() => clearFetcher(searchValue, query), 500, [ + clearFetcher, + searchValue, + query, + ]); + + // update the fetched keys when the fetch status changes + useEffect(() => { + if (status === 'success' && data?.payload) { + setKeys(data?.payload); + } else { + setKeys([]); + } + }, [data?.payload, status]); + + return { + keys, + results, + isFetching, + }; +}; diff --git a/frontend/src/hooks/queryBuilder/useIsValidTag.ts b/frontend/src/hooks/queryBuilder/useIsValidTag.ts new file mode 100644 index 0000000000..216971b0ac --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useIsValidTag.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import { OperatorType } from './useOperatorType'; + +const validationMapper: Record< + OperatorType, + (resultLength: number) => boolean +> = { + SINGLE_VALUE: (resultLength: number) => resultLength === 1, + MULTIPLY_VALUE: (resultLength: number) => resultLength >= 1, + NON_VALUE: (resultLength: number) => resultLength === 0, + NOT_VALID: () => false, +}; + +export const useIsValidTag = ( + operatorType: OperatorType, + resultLength: number, +): boolean => + useMemo(() => validationMapper[operatorType]?.(resultLength), [ + operatorType, + resultLength, + ]); diff --git a/frontend/src/hooks/queryBuilder/useOperatorType.ts b/frontend/src/hooks/queryBuilder/useOperatorType.ts new file mode 100644 index 0000000000..a387708301 --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useOperatorType.ts @@ -0,0 +1,27 @@ +import { OPERATORS } from 'constants/queryBuilder'; + +export type OperatorType = + | 'SINGLE_VALUE' + | 'MULTIPLY_VALUE' + | 'NON_VALUE' + | 'NOT_VALID'; + +const operatorTypeMapper: Record = { + [OPERATORS.IN]: 'MULTIPLY_VALUE', + [OPERATORS.NIN]: 'MULTIPLY_VALUE', + [OPERATORS.EXISTS]: 'NON_VALUE', + [OPERATORS.NOT_EXISTS]: 'NON_VALUE', + [OPERATORS.LTE]: 'SINGLE_VALUE', + [OPERATORS.LT]: 'SINGLE_VALUE', + [OPERATORS.GTE]: 'SINGLE_VALUE', + [OPERATORS.GT]: 'SINGLE_VALUE', + [OPERATORS.LIKE]: 'SINGLE_VALUE', + [OPERATORS.NLIKE]: 'SINGLE_VALUE', + [OPERATORS.CONTAINS]: 'SINGLE_VALUE', + [OPERATORS.NOT_CONTAINS]: 'SINGLE_VALUE', + [OPERATORS.EQUALS]: 'SINGLE_VALUE', + [OPERATORS.NOT_EQUALS]: 'SINGLE_VALUE', +}; + +export const useOperatorType = (operator: string): OperatorType => + operatorTypeMapper[operator] || 'NOT_VALID'; diff --git a/frontend/src/hooks/queryBuilder/useOperators.ts b/frontend/src/hooks/queryBuilder/useOperators.ts new file mode 100644 index 0000000000..b56fed1dee --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useOperators.ts @@ -0,0 +1,20 @@ +import { AttributeKeyOptions } from 'api/queryBuilder/getAttributesKeysValues'; +import { QUERY_BUILDER_OPERATORS_BY_TYPES } from 'constants/queryBuilder'; +import { useMemo } from 'react'; + +type IOperators = + | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal + | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string + | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.boolean + | typeof QUERY_BUILDER_OPERATORS_BY_TYPES.number; + +export const useOperators = ( + key: string, + keys: AttributeKeyOptions[], +): IOperators => + useMemo(() => { + const currentKey = keys?.find((el) => el.key === key); + return currentKey + ? QUERY_BUILDER_OPERATORS_BY_TYPES[currentKey.dataType] + : QUERY_BUILDER_OPERATORS_BY_TYPES.universal; + }, [keys, key]); diff --git a/frontend/src/hooks/queryBuilder/useOptions.ts b/frontend/src/hooks/queryBuilder/useOptions.ts new file mode 100644 index 0000000000..3b378f0684 --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useOptions.ts @@ -0,0 +1,78 @@ +import { AttributeKeyOptions } from 'api/queryBuilder/getAttributesKeysValues'; +import { Option } from 'container/QueryBuilder/type'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useOperators } from './useOperators'; + +export const useOptions = ( + key: string, + keys: AttributeKeyOptions[], + operator: string, + searchValue: string, + isMulti: boolean, + isValidOperator: boolean, + isExist: boolean, + results: string[], + result: string[], +): Option[] => { + const [options, setOptions] = useState([]); + const operators = useOperators(key, keys); + + const updateOptions = useCallback(() => { + if (!key) { + setOptions( + searchValue + ? [{ value: searchValue }, ...keys.map((k) => ({ value: k.key }))] + : keys?.map((k) => ({ value: k.key })), + ); + } else if (key && !operator) { + setOptions( + operators.map((o) => ({ + value: `${key} ${o}`, + label: `${key} ${o.replace('_', ' ')}`, + })), + ); + } else if (key && operator) { + if (isMulti) { + setOptions(results.map((r) => ({ value: `${r}` }))); + } else if (isExist) { + setOptions([]); + } else if (isValidOperator) { + const hasAllResults = result.every((val) => results.includes(val)); + const values = results.map((r) => ({ + value: `${key} ${operator} ${r}`, + })); + const options = hasAllResults + ? values + : [{ value: searchValue }, ...values]; + setOptions(options); + } + } + }, [ + isExist, + isMulti, + isValidOperator, + key, + keys, + operator, + operators, + result, + results, + searchValue, + ]); + + useEffect(() => { + updateOptions(); + }, [updateOptions]); + + return useMemo( + () => + options?.map((option) => { + if (isMulti) { + return { ...option, selected: searchValue.includes(option.value) }; + } + return option; + }), + [isMulti, options, searchValue], + ); +}; diff --git a/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts b/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts new file mode 100644 index 0000000000..cd1e7cec2f --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useSetCurrentKeyAndOperator.ts @@ -0,0 +1,32 @@ +import { AttributeKeyOptions } from 'api/queryBuilder/getAttributesKeysValues'; +import { useMemo } from 'react'; +import { getCountOfSpace } from 'utils/getCountOfSpace'; +import { separateSearchValue } from 'utils/separateSearchValue'; + +type ICurrentKeyAndOperator = [string, string, string[]]; + +export const useSetCurrentKeyAndOperator = ( + value: string, + keys: AttributeKeyOptions[], +): ICurrentKeyAndOperator => { + const [key, operator, result] = useMemo(() => { + let key = ''; + let operator = ''; + let result: string[] = []; + + if (value) { + const [tKey, tOperator, tResult] = separateSearchValue(value); + const isSuggestKey = keys?.some((el) => el.key === tKey); + + if (getCountOfSpace(value) >= 1 || isSuggestKey) { + key = tKey || ''; + operator = tOperator || ''; + result = tResult.filter((el) => el); + } + } + + return [key, operator, result]; + }, [value, keys]); + + return [key, operator, result]; +}; diff --git a/frontend/src/hooks/queryBuilder/useTag.ts b/frontend/src/hooks/queryBuilder/useTag.ts new file mode 100644 index 0000000000..38aef6fde8 --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useTag.ts @@ -0,0 +1,53 @@ +import { isExistsNotExistsOperator } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useCallback, useState } from 'react'; + +type IUseTag = { + handleAddTag: (value: string) => void; + handleClearTag: (value: string) => void; + tags: string[]; +}; + +/** + * A custom React hook for handling tags. + * @param {string} key - A string value to identify tags. + * @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid. + * @param {boolean} isFreeText - A boolean value to indicate whether free text is allowed. + * @param {function} handleSearch - A callback function to handle search. + * @returns {IUseTag} The return object containing handlers and tags. + */ +export const useTag = ( + key: string, + isValidTag: boolean, + isFreeText: boolean, + handleSearch: (value: string) => void, +): IUseTag => { + const [tags, setTags] = useState([]); + + /** + * Adds a new tag to the tag list. + * @param {string} value - The tag value to be added. + */ + const handleAddTag = useCallback( + (value: string): void => { + if ( + (value && key && isValidTag) || + isFreeText || + isExistsNotExistsOperator(value) + ) { + setTags((prevTags) => [...prevTags, value]); + handleSearch(''); + } + }, + [key, isValidTag, isFreeText, handleSearch], + ); + + /** + * Removes a tag from the tag list. + * @param {string} value - The tag value to be removed. + */ + const handleClearTag = useCallback((value: string): void => { + setTags((prevTags) => prevTags.filter((v) => v !== value)); + }, []); + + return { handleAddTag, handleClearTag, tags }; +}; diff --git a/frontend/src/hooks/queryBuilder/useTagValidation.ts b/frontend/src/hooks/queryBuilder/useTagValidation.ts new file mode 100644 index 0000000000..fd3932b93a --- /dev/null +++ b/frontend/src/hooks/queryBuilder/useTagValidation.ts @@ -0,0 +1,35 @@ +import { QUERY_BUILDER_SEARCH_VALUES } from 'constants/queryBuilder'; +import { useMemo } from 'react'; +import { checkStringEndsWithSpace } from 'utils/checkStringEndsWithSpace'; + +import { useIsValidTag } from './useIsValidTag'; +import { useOperatorType } from './useOperatorType'; + +type ITagValidation = { + isValidTag: boolean; + isExist: boolean; + isValidOperator: boolean; + isMulti: boolean; + isFreeText: boolean; +}; + +export const useTagValidation = ( + value: string, + operator: string, + result: string[], +): ITagValidation => { + const operatorType = useOperatorType(operator); + const isValidTag = useIsValidTag(operatorType, result.length); + + const { isExist, isValidOperator, isMulti, isFreeText } = useMemo(() => { + const isExist = operatorType === QUERY_BUILDER_SEARCH_VALUES.NON; + const isValidOperator = + operatorType !== QUERY_BUILDER_SEARCH_VALUES.NOT_VALID; + const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY; + const isFreeText = operator === '' && !checkStringEndsWithSpace(value); + + return { isExist, isValidOperator, isMulti, isFreeText }; + }, [operator, operatorType, value]); + + return { isValidTag, isExist, isValidOperator, isMulti, isFreeText }; +}; diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index 905aa85886..90e72bdff4 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -1,7 +1,9 @@ // ** Helpers // ** Constants -import { initialQueryBuilderFormValues } from 'constants/queryBuilder'; -import { mapOfOperators } from 'constants/queryBuilder'; +import { + initialQueryBuilderFormValues, + mapOfOperators, +} from 'constants/queryBuilder'; import { createNewQueryName, MAX_QUERIES, diff --git a/frontend/src/utils/checkStringEndsWithSpace.ts b/frontend/src/utils/checkStringEndsWithSpace.ts new file mode 100644 index 0000000000..99769b151b --- /dev/null +++ b/frontend/src/utils/checkStringEndsWithSpace.ts @@ -0,0 +1,4 @@ +export const checkStringEndsWithSpace = (str: string): boolean => { + const endSpace = / $/; + return endSpace.test(str); +}; diff --git a/frontend/src/utils/getCountOfSpace.ts b/frontend/src/utils/getCountOfSpace.ts new file mode 100644 index 0000000000..168520afd2 --- /dev/null +++ b/frontend/src/utils/getCountOfSpace.ts @@ -0,0 +1 @@ +export const getCountOfSpace = (s: string): number => s.split(' ').length - 1; diff --git a/frontend/src/utils/getSearchParams.ts b/frontend/src/utils/getSearchParams.ts new file mode 100644 index 0000000000..7de4457f75 --- /dev/null +++ b/frontend/src/utils/getSearchParams.ts @@ -0,0 +1,9 @@ +export const getSearchParams = (newParams: { + [key: string]: string; +}): URLSearchParams => { + const params = new URLSearchParams(); + Object.entries(newParams).forEach(([key, value]) => { + params.set(key, value); + }); + return params; +}; diff --git a/frontend/src/utils/separateSearchValue.ts b/frontend/src/utils/separateSearchValue.ts new file mode 100644 index 0000000000..4499c478a8 --- /dev/null +++ b/frontend/src/utils/separateSearchValue.ts @@ -0,0 +1,12 @@ +import { OPERATORS } from 'constants/queryBuilder'; + +export const separateSearchValue = ( + value: string, +): [string, string, string[]] => { + const separatedString = value.split(' '); + const [key, operator, ...result] = separatedString; + if (operator === OPERATORS.IN || operator === OPERATORS.NIN) { + return [key, operator, result]; + } + return [key, operator, Array(result.join(' '))]; +};