feat(builder): add having filter (#2567)

* feat(builder): add having filter

* feat(builder): add having filter

* feat(builder): add having filter

* feat: return initial query builder

* fix: having filter operators and values
This commit is contained in:
Yevhen Shevchenko 2023-04-19 07:25:18 +03:00 committed by GitHub
parent 63570c847a
commit dd25ad95c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 727 additions and 107 deletions

View File

@ -1,9 +1,11 @@
// ** TODO: use it for creating formula names
// import { createNewFormulaName } from 'lib/newQueryBuilder/createNewFormulaName';
// ** Helpers // ** Helpers
import { createNewQueryName } from 'lib/newQueryBuilder/createNewQueryName'; import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { LocalDataType } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { LocalDataType } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQueryForm } from 'types/api/queryBuilder/queryBuilderData'; import {
Having,
IBuilderFormula,
IBuilderQueryForm,
} from 'types/api/queryBuilder/queryBuilderData';
import { import {
BoolOperators, BoolOperators,
DataSource, DataSource,
@ -14,6 +16,16 @@ import {
TracesAggregatorOperator, TracesAggregatorOperator,
} from 'types/common/queryBuilder'; } from 'types/common/queryBuilder';
export const MAX_FORMULAS = 20;
export const MAX_QUERIES = 26;
export const formulasNames: string[] = Array.from(
Array(MAX_FORMULAS),
(_, i) => `F${i + 1}`,
);
const alpha: number[] = Array.from(Array(MAX_QUERIES), (_, i) => i + 65);
export const alphabet: string[] = alpha.map((str) => String.fromCharCode(str));
export enum QueryBuilderKeys { export enum QueryBuilderKeys {
GET_AGGREGATE_ATTRIBUTE = 'GET_AGGREGATE_ATTRIBUTE', GET_AGGREGATE_ATTRIBUTE = 'GET_AGGREGATE_ATTRIBUTE',
GET_AGGREGATE_KEYS = 'GET_AGGREGATE_KEYS', GET_AGGREGATE_KEYS = 'GET_AGGREGATE_KEYS',
@ -27,9 +39,15 @@ export const mapOfOperators: Record<DataSource, string[]> = {
export const mapOfFilters: Record<DataSource, string[]> = { export const mapOfFilters: Record<DataSource, string[]> = {
// eslint-disable-next-line sonarjs/no-duplicate-string // eslint-disable-next-line sonarjs/no-duplicate-string
metrics: ['Having', 'Aggregation interval'], metrics: ['Aggregation interval', 'Having'],
logs: ['Limit', 'Having', 'Order by', 'Aggregation interval'], logs: ['Order by', 'Limit', 'Having', 'Aggregation interval'],
traces: ['Limit', 'Having', 'Order by', 'Aggregation interval'], traces: ['Order by', 'Limit', 'Having', 'Aggregation interval'],
};
export const initialHavingValues: Having = {
columnName: '',
op: '',
value: [],
}; };
export const initialAggregateAttribute: IBuilderQueryForm['aggregateAttribute'] = { export const initialAggregateAttribute: IBuilderQueryForm['aggregateAttribute'] = {
@ -41,7 +59,7 @@ export const initialAggregateAttribute: IBuilderQueryForm['aggregateAttribute']
export const initialQueryBuilderFormValues: IBuilderQueryForm = { export const initialQueryBuilderFormValues: IBuilderQueryForm = {
dataSource: DataSource.METRICS, dataSource: DataSource.METRICS,
queryName: createNewQueryName([]), queryName: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
aggregateOperator: Object.values(MetricAggregateOperator)[0], aggregateOperator: Object.values(MetricAggregateOperator)[0],
aggregateAttribute: initialAggregateAttribute, aggregateAttribute: initialAggregateAttribute,
tagFilters: { items: [], op: 'AND' }, tagFilters: { items: [], op: 'AND' },
@ -56,6 +74,16 @@ export const initialQueryBuilderFormValues: IBuilderQueryForm = {
reduceTo: '', reduceTo: '',
}; };
export const initialFormulaBuilderFormValues: IBuilderFormula = {
label: createNewBuilderItemName({
existNames: [],
sourceNames: formulasNames,
}),
expression: '',
disabled: false,
legend: '',
};
export const operatorsByTypes: Record<LocalDataType, string[]> = { export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators), string: Object.values(StringOperators),
number: Object.values(NumberOperators), number: Object.values(NumberOperators),
@ -136,3 +164,14 @@ export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
OPERATORS.NOT_CONTAINS, OPERATORS.NOT_CONTAINS,
], ],
}; };
export const HAVING_OPERATORS: string[] = [
OPERATORS.EQUALS,
OPERATORS.NOT_EQUALS,
OPERATORS.IN,
OPERATORS.NIN,
OPERATORS.GTE,
OPERATORS.GT,
OPERATORS.LTE,
OPERATORS.LT,
];

View File

@ -1,14 +1,13 @@
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { Button, Col, Row } from 'antd'; import { Button, Col, Row } from 'antd';
import { MAX_FORMULAS, MAX_QUERIES } from 'constants/queryBuilder';
// ** Hooks // ** Hooks
import { useQueryBuilder } from 'hooks/useQueryBuilder'; import { useQueryBuilder } from 'hooks/useQueryBuilder';
import { MAX_FORMULAS } from 'lib/newQueryBuilder/createNewFormulaName';
// ** Constants // ** Constants
import { MAX_QUERIES } from 'lib/newQueryBuilder/createNewQueryName';
import React, { memo, useEffect, useMemo } from 'react'; import React, { memo, useEffect, useMemo } from 'react';
// ** Components // ** Components
import { Query } from './components'; import { Formula, Query } from './components';
// ** Types // ** Types
import { QueryBuilderProps } from './QueryBuilder.interfaces'; import { QueryBuilderProps } from './QueryBuilder.interfaces';
// ** Styles // ** Styles
@ -21,6 +20,7 @@ export const QueryBuilder = memo(function QueryBuilder({
queryBuilderData, queryBuilderData,
setupInitialDataSource, setupInitialDataSource,
addNewQuery, addNewQuery,
addNewFormula,
} = useQueryBuilder(); } = useQueryBuilder();
useEffect(() => { useEffect(() => {
@ -39,7 +39,7 @@ export const QueryBuilder = memo(function QueryBuilder({
); );
const isDisabledFormulaButton = useMemo( const isDisabledFormulaButton = useMemo(
() => queryBuilderData.queryData.length >= MAX_FORMULAS, () => queryBuilderData.queryFormulas.length >= MAX_FORMULAS,
[queryBuilderData], [queryBuilderData],
); );
@ -58,6 +58,11 @@ export const QueryBuilder = memo(function QueryBuilder({
/> />
</Col> </Col>
))} ))}
{queryBuilderData.queryFormulas.map((formula, index) => (
<Col key={formula.label} span={24}>
<Formula formula={formula} index={index} />
</Col>
))}
</Row> </Row>
</Col> </Col>
@ -75,6 +80,7 @@ export const QueryBuilder = memo(function QueryBuilder({
<Col> <Col>
<Button <Button
disabled={isDisabledFormulaButton} disabled={isDisabledFormulaButton}
onClick={addNewFormula}
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
> >

View File

@ -1,5 +1,5 @@
import { MinusSquareOutlined, PlusSquareOutlined } from '@ant-design/icons'; import { MinusSquareOutlined, PlusSquareOutlined } from '@ant-design/icons';
import { Typography } from 'antd'; import { Col, Typography } from 'antd';
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
const IconCss = css` const IconCss = css`
@ -15,14 +15,8 @@ export const StyledIconClose = styled(MinusSquareOutlined)`
${IconCss} ${IconCss}
`; `;
export const StyledWrapper = styled.div` export const StyledInner = styled(Col)`
display: flex;
flex-direction: column;
width: fit-content; width: fit-content;
`;
export const StyledInner = styled.div`
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 0.875rem; margin-bottom: 0.875rem;

View File

@ -1,3 +1,4 @@
import { Col, Row } from 'antd';
import React, { Fragment, memo, ReactNode, useState } from 'react'; import React, { Fragment, memo, ReactNode, useState } from 'react';
// ** Types // ** Types
@ -39,12 +40,14 @@ export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
}); });
return ( return (
<div> <Row>
<StyledInner onClick={handleToggleOpenFilters}> <Col span={24}>
{isOpenedFilters ? <StyledIconClose /> : <StyledIconOpen />} <StyledInner onClick={handleToggleOpenFilters}>
{!isOpenedFilters && <span>Add conditions for {filtersTexts}</span>} {isOpenedFilters ? <StyledIconClose /> : <StyledIconOpen />}
</StyledInner> {!isOpenedFilters && <span>Add conditions for {filtersTexts}</span>}
{isOpenedFilters && children} </StyledInner>
</div> </Col>
{isOpenedFilters && <Col span={24}>{children}</Col>}
</Row>
); );
}); });

View File

@ -2,8 +2,8 @@ import styled from 'styled-components';
export const StyledLabel = styled.div` export const StyledLabel = styled.div`
padding: 0 0.6875rem; padding: 0 0.6875rem;
width: fit-content;
min-height: 2rem; min-height: 2rem;
width: 100%;
display: inline-flex; display: inline-flex;
white-space: nowrap; white-space: nowrap;
align-items: center; align-items: center;

View File

@ -1,2 +1,3 @@
// TODO: temporary type import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
export type FormulaProps = { test: string };
export type FormulaProps = { formula: IBuilderFormula; index: number };

View File

@ -1,5 +1,73 @@
import React from 'react'; import { Col, Input } from 'antd';
// ** Components
import { ListItemWrapper, ListMarker } from 'container/QueryBuilder/components';
// ** Hooks
import { useQueryBuilder } from 'hooks/useQueryBuilder';
import React, { ChangeEvent, useCallback } from 'react';
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
export function Formula(): JSX.Element { // ** Types
return <div>null</div>; import { FormulaProps } from './Formula.interfaces';
const { TextArea } = Input;
export function Formula({ index, formula }: FormulaProps): JSX.Element {
const { removeEntityByIndex, handleSetFormulaData } = useQueryBuilder();
const handleDelete = useCallback(() => {
removeEntityByIndex('queryFormulas', index);
}, [index, removeEntityByIndex]);
const handleToggleDisableFormula = useCallback((): void => {
const newFormula: IBuilderFormula = {
...formula,
disabled: !formula.disabled,
};
handleSetFormulaData(index, newFormula);
}, [index, formula, handleSetFormulaData]);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
const newFormula: IBuilderFormula = {
...formula,
[name]: value,
};
handleSetFormulaData(index, newFormula);
},
[index, formula, handleSetFormulaData],
);
return (
<ListItemWrapper onDelete={handleDelete}>
<Col span={24}>
<ListMarker
isDisabled={formula.disabled}
onDisable={handleToggleDisableFormula}
labelName={formula.label}
index={index}
/>
</Col>
<Col span={24}>
<TextArea
name="expression"
onChange={handleChange}
size="middle"
value={formula.expression}
rows={2}
/>
</Col>
<Col span={24}>
<Input
name="legend"
onChange={handleChange}
size="middle"
value={formula.legend}
addonBefore="Legend Format"
/>
</Col>
</ListItemWrapper>
);
} }

View File

@ -0,0 +1,3 @@
import { PropsWithChildren } from 'react';
export type ListItemWrapperProps = PropsWithChildren & { onDelete: () => void };

View File

@ -4,7 +4,7 @@ import styled from 'styled-components';
export const StyledDeleteEntity = styled(CloseCircleOutlined)` export const StyledDeleteEntity = styled(CloseCircleOutlined)`
position: absolute; position: absolute;
top: 0.9375rem; top: 0.5rem;
right: 0.9375rem; right: 0.9375rem;
z-index: 1; z-index: 1;
cursor: pointer; cursor: pointer;

View File

@ -0,0 +1,18 @@
import React from 'react';
// ** Types
import { ListItemWrapperProps } from './ListItemWrapper.interfaces';
// ** Styles
import { StyledDeleteEntity, StyledRow } from './ListItemWrapper.styled';
export function ListItemWrapper({
children,
onDelete,
}: ListItemWrapperProps): JSX.Element {
return (
<StyledRow gutter={[0, 15]}>
<StyledDeleteEntity onClick={onDelete} />
{children}
</StyledRow>
);
}

View File

@ -0,0 +1 @@
export { ListItemWrapper } from './ListItemWrapper';

View File

@ -5,7 +5,7 @@ export type ListMarkerProps = {
labelName: string; labelName: string;
index: number; index: number;
className?: string; className?: string;
isAvailableToDisable: boolean; isAvailableToDisable?: boolean;
toggleDisabled: (index: number) => void; onDisable: (index: number) => void;
style?: CSSProperties; style?: CSSProperties;
}; };

View File

@ -11,16 +11,16 @@ export const ListMarker = memo(function ListMarker({
isDisabled, isDisabled,
labelName, labelName,
index, index,
isAvailableToDisable, isAvailableToDisable = true,
className, className,
toggleDisabled, onDisable,
style, style,
}: ListMarkerProps): JSX.Element { }: ListMarkerProps): JSX.Element {
const buttonProps: Partial<ButtonProps> = isAvailableToDisable const buttonProps: Partial<ButtonProps> = isAvailableToDisable
? { ? {
type: isDisabled ? 'default' : 'primary', type: isDisabled ? 'default' : 'primary',
icon: isDisabled ? <EyeInvisibleFilled /> : <EyeFilled />, icon: isDisabled ? <EyeInvisibleFilled /> : <EyeFilled />,
onClick: (): void => toggleDisabled(index), onClick: (): void => onDisable(index),
} }
: { type: 'primary' }; : { type: 'primary' };

View File

@ -11,11 +11,13 @@ import {
AdditionalFiltersToggler, AdditionalFiltersToggler,
DataSourceDropdown, DataSourceDropdown,
FilterLabel, FilterLabel,
ListItemWrapper,
ListMarker, ListMarker,
} from 'container/QueryBuilder/components'; } from 'container/QueryBuilder/components';
import { import {
AggregatorFilter, AggregatorFilter,
GroupByFilter, GroupByFilter,
HavingFilter,
OperatorsSelect, OperatorsSelect,
ReduceToFilter, ReduceToFilter,
} from 'container/QueryBuilder/filters'; } from 'container/QueryBuilder/filters';
@ -29,16 +31,15 @@ import { findDataTypeOfOperator } from 'lib/query/findDataTypeOfOperator';
import React, { memo, useCallback, useMemo } from 'react'; import React, { memo, useCallback, useMemo } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { import {
Having,
IBuilderQueryForm, IBuilderQueryForm,
TagFilter, TagFilter,
} from 'types/api/queryBuilder/queryBuilderData'; } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { transformToUpperCase } from 'utils/transformToUpperCase'; import { transformToUpperCase } from 'utils/transformToUpperCase';
// ** Types // ** Types
import { QueryProps } from './Query.interfaces'; import { QueryProps } from './Query.interfaces';
// ** Styles
import { StyledDeleteEntity, StyledFilterRow, StyledRow } from './Query.styled';
export const Query = memo(function Query({ export const Query = memo(function Query({
index, index,
@ -116,6 +117,7 @@ export const Query = memo(function Query({
const newQuery: IBuilderQueryForm = { const newQuery: IBuilderQueryForm = {
...query, ...query,
aggregateAttribute: value, aggregateAttribute: value,
having: [],
}; };
handleSetQueryData(index, newQuery); handleSetQueryData(index, newQuery);
@ -192,6 +194,15 @@ export const Query = memo(function Query({
[index, query, handleSetQueryData], [index, query, handleSetQueryData],
); );
const handleChangeHavingFilter = useCallback(
(having: Having[]) => {
const newQuery: IBuilderQueryForm = { ...query, having };
handleSetQueryData(index, newQuery);
},
[index, query, handleSetQueryData],
);
const handleDeleteQuery = useCallback(() => { const handleDeleteQuery = useCallback(() => {
removeEntityByIndex('queryData', index); removeEntityByIndex('queryData', index);
}, [removeEntityByIndex, index]); }, [removeEntityByIndex, index]);
@ -246,14 +257,13 @@ export const Query = memo(function Query({
); );
return ( return (
<StyledRow gutter={[0, 15]}> <ListItemWrapper onDelete={handleDeleteQuery}>
<StyledDeleteEntity onClick={handleDeleteQuery} />
<Col span={24}> <Col span={24}>
<Row align="middle" justify="space-between"> <Row align="middle">
<Col> <Col>
<ListMarker <ListMarker
isDisabled={query.disabled} isDisabled={query.disabled}
toggleDisabled={handleToggleDisableQuery} onDisable={handleToggleDisableQuery}
labelName={query.queryName} labelName={query.queryName}
index={index} index={index}
isAvailableToDisable={isAvailableToDisable} isAvailableToDisable={isAvailableToDisable}
@ -267,10 +277,18 @@ export const Query = memo(function Query({
) : ( ) : (
<FilterLabel label={transformToUpperCase(query.dataSource)} /> <FilterLabel label={transformToUpperCase(query.dataSource)} />
)} )}
{isMatricsDataSource && <FilterLabel label="WHERE" />}
</Col> </Col>
<Col span={isMatricsDataSource ? 17 : 20}> <Col flex="1">
<QueryBuilderSearch query={query} onChange={handleChangeTagFilters} /> <Row gutter={[11, 5]}>
{isMatricsDataSource && (
<Col>
<FilterLabel label="WHERE" />
</Col>
)}
<Col flex="1">
<QueryBuilderSearch query={query} onChange={handleChangeTagFilters} />
</Col>
</Row>
</Col> </Col>
</Row> </Row>
</Col> </Col>
@ -291,6 +309,7 @@ export const Query = memo(function Query({
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col span={11} offset={2}> <Col span={11} offset={2}>
<Row gutter={[11, 5]}> <Row gutter={[11, 5]}>
<Col flex="5.93rem"> <Col flex="5.93rem">
@ -307,31 +326,56 @@ export const Query = memo(function Query({
</Col> </Col>
<Col span={24}> <Col span={24}>
<AdditionalFiltersToggler listOfAdditionalFilter={listOfAdditionalFilters}> <AdditionalFiltersToggler listOfAdditionalFilter={listOfAdditionalFilters}>
{!isMatricsDataSource && ( <Row gutter={[0, 11]} justify="space-between">
<StyledFilterRow gutter={[11, 5]} justify="space-around"> {!isMatricsDataSource && (
<Col span={2}> <Col span={11}>
<FilterLabel label="Order by" /> <Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Limit" />
</Col>
<Col flex="1 1 12.5rem">
<LimitFilter query={query} onChange={handleChangeLimit} />
</Col>
</Row>
</Col> </Col>
<Col span={10}> )}
<OrderByFilter query={query} onChange={handleChangeOrderByKeys} /> {query.aggregateOperator !== StringOperators.NOOP && (
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter onChange={handleChangeHavingFilter} query={query} />
</Col>
</Row>
</Col> </Col>
<Col span={1.5}> )}
<FilterLabel label="Limit" /> {!isMatricsDataSource && (
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Order by" />
</Col>
<Col flex="1 1 12.5rem">
<OrderByFilter query={query} onChange={handleChangeOrderByKeys} />
</Col>
</Row>
</Col> </Col>
<Col span={10}> )}
<LimitFilter query={query} onChange={handleChangeLimit} />
</Col> <Col span={11}>
</StyledFilterRow> <Row gutter={[11, 5]}>
)} <Col flex="5.93rem">
<Row gutter={[11, 5]}> <FilterLabel label="Aggregate Every" />
<Col span={3}> </Col>
<FilterLabel label="Aggregate Every" /> <Col flex="1 1 6rem">
</Col> <AggregateEveryFilter
<Col span={8}> query={query}
<AggregateEveryFilter onChange={handleChangeAggregateEvery}
query={query} />
onChange={handleChangeAggregateEvery} </Col>
/> </Row>
</Col> </Col>
</Row> </Row>
</AdditionalFiltersToggler> </AdditionalFiltersToggler>
@ -344,6 +388,6 @@ export const Query = memo(function Query({
addonBefore="Legend Format" addonBefore="Legend Format"
/> />
</Row> </Row>
</StyledRow> </ListItemWrapper>
); );
}); });

View File

@ -2,5 +2,6 @@ export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
export { DataSourceDropdown } from './DataSourceDropdown'; export { DataSourceDropdown } from './DataSourceDropdown';
export { FilterLabel } from './FilterLabel'; export { FilterLabel } from './FilterLabel';
export { Formula } from './Formula'; export { Formula } from './Formula';
export { ListItemWrapper } from './ListItemWrapper';
export { ListMarker } from './ListMarker'; export { ListMarker } from './ListMarker';
export { Query } from './Query'; export { Query } from './Query';

View File

@ -0,0 +1,9 @@
import {
Having,
IBuilderQueryForm,
} from 'types/api/queryBuilder/queryBuilderData';
export type HavingFilterProps = {
query: IBuilderQueryForm;
onChange: (having: Having[]) => void;
};

View File

@ -0,0 +1,196 @@
import { Select } from 'antd';
// ** Constants
import { HAVING_OPERATORS, initialHavingValues } from 'constants/queryBuilder';
// ** Hooks
import { useTagValidation } from 'hooks/queryBuilder/useTagValidation';
import {
transformFromStringToHaving,
transformHavingToStringValue,
} from 'lib/query/transformQueryBuilderData';
// ** Helpers
import { transformStringWithPrefix } from 'lib/query/transformStringWithPrefix';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Having } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
// ** Types
import { HavingFilterProps } from './HavingFilter.interfaces';
const { Option } = Select;
export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
const [localValues, setLocalValues] = useState<string[]>([]);
const [currentFormValue, setCurrentFormValue] = useState<Having>(
initialHavingValues,
);
const { isMulti } = useTagValidation(
searchText,
currentFormValue.op,
currentFormValue.value,
);
const aggregatorAttribute = useMemo(
() =>
transformStringWithPrefix({
str: query.aggregateAttribute.key,
prefix: query.aggregateAttribute.type || '',
condition: !query.aggregateAttribute.isColumn,
}),
[query],
);
const columnName = useMemo(
() => `${query.aggregateOperator.toUpperCase()}(${aggregatorAttribute})`,
[query, aggregatorAttribute],
);
const aggregatorOptions: SelectOption<string, string>[] = useMemo(
() => [{ label: columnName, value: columnName }],
[columnName],
);
const getHavingObject = useCallback((currentSearch: string): Having => {
const textArr = currentSearch.split(' ');
const [columnName = '', op = '', ...value] = textArr;
return { columnName, op, value };
}, []);
const generateOptions = useCallback(
(search: string): void => {
const [aggregator = '', op = '', ...restValue] = search.split(' ');
let newOptions: SelectOption<string, string>[] = [];
const isAggregatorExist = columnName
.toLowerCase()
.includes(search.toLowerCase());
const isAggregatorChosen = aggregator === columnName;
if (isAggregatorExist || aggregator === '') {
newOptions = aggregatorOptions;
}
if ((isAggregatorChosen && op === '') || op) {
const filteredOperators = HAVING_OPERATORS.filter((num) =>
num.toLowerCase().includes(op.toLowerCase()),
);
newOptions = filteredOperators.map((opt) => ({
label: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
value: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
}));
}
setOptions(newOptions);
},
[columnName, aggregatorOptions],
);
const isValidHavingValue = (search: string): boolean => {
const values = getHavingObject(search).value.join(' ');
if (values) {
const numRegexp = /^[^a-zA-Z]*$/;
return numRegexp.test(values);
}
return true;
};
const handleSearch = (search: string): void => {
const trimmedSearch = search.replace(/\s\s+/g, ' ').trimStart();
const currentSearch = isMulti
? trimmedSearch
: trimmedSearch.split(' ').slice(0, 3).join(' ');
const isValidSearch = isValidHavingValue(currentSearch);
if (isValidSearch) {
setSearchText(currentSearch);
}
};
const resetChanges = (): void => {
handleSearch('');
setCurrentFormValue(initialHavingValues);
setOptions(aggregatorOptions);
};
const handleChange = (values: string[]): void => {
const having: Having[] = values.map(transformFromStringToHaving);
const isSelectable: boolean =
currentFormValue.value.length > 0 &&
currentFormValue.value.every((value) => !!value);
if (isSelectable) {
onChange(having);
resetChanges();
}
};
const handleSelect = (currentValue: string): void => {
const { columnName, op, value } = getHavingObject(currentValue);
const isCompletedValue = value.every((item) => !!item);
const isClearSearch = isCompletedValue && columnName && op;
handleSearch(isClearSearch ? '' : currentValue);
};
const parseSearchText = useCallback(
(text: string) => {
const { columnName, op, value } = getHavingObject(text);
setCurrentFormValue({ columnName, op, value });
generateOptions(text);
},
[generateOptions, getHavingObject],
);
const handleDeselect = (value: string): void => {
const result = localValues.filter((item) => item !== value);
setLocalValues(result);
};
useEffect(() => {
parseSearchText(searchText);
}, [searchText, parseSearchText]);
useEffect(() => {
setLocalValues(transformHavingToStringValue(having));
}, [having]);
return (
<Select
mode="multiple"
onSearch={handleSearch}
searchValue={searchText}
value={localValues}
data-testid="havingSelect"
disabled={!query.aggregateAttribute.key}
style={{ width: '100%' }}
notFoundContent={currentFormValue.value.length === 0 ? undefined : null}
placeholder="Count(operation) > 5"
onDeselect={handleDeselect}
onBlur={resetChanges}
onChange={handleChange}
onSelect={handleSelect}
>
{options.map((opt) => (
<Option key={opt.value} value={opt.value} title="havingOption">
{opt.label}
</Option>
))}
</Select>
);
}

View File

@ -0,0 +1,166 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Constants
import {
HAVING_OPERATORS,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { transformFromStringToHaving } from 'lib/query/transformQueryBuilderData';
import React from 'react';
// ** Types
import { IBuilderQueryForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
// ** Components
import { HavingFilter } from '../HavingFilter';
const valueWithAttributeAndOperator: IBuilderQueryForm = {
...initialQueryBuilderFormValues,
dataSource: DataSource.LOGS,
aggregateOperator: 'SUM',
aggregateAttribute: {
isColumn: false,
key: 'bytes',
type: 'tag',
dataType: 'float64',
},
};
describe('Having filter behaviour', () => {
test('Having filter render is rendered', () => {
const mockFn = jest.fn();
const { unmount } = render(
<HavingFilter query={initialQueryBuilderFormValues} onChange={mockFn} />,
);
const selectId = 'havingSelect';
const select = screen.getByTestId(selectId);
expect(select).toBeInTheDocument();
unmount();
});
test('Having render is disabled initially', () => {
const mockFn = jest.fn();
const { unmount } = render(
<HavingFilter query={initialQueryBuilderFormValues} onChange={mockFn} />,
);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
unmount();
});
test('Is having filter is enable', () => {
const mockFn = jest.fn();
const { unmount } = render(
<HavingFilter query={valueWithAttributeAndOperator} onChange={mockFn} />,
);
const input = screen.getByRole('combobox');
expect(input).toBeEnabled();
unmount();
});
test('Autocomplete in the having filter', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
const constructedAttribute = 'SUM(tag_bytes)';
const optionTestTitle = 'havingOption';
const { unmount } = render(
<HavingFilter query={valueWithAttributeAndOperator} onChange={onChange} />,
);
// get input
const input = screen.getByRole('combobox');
// click on the select
await user.click(input);
// show predefined options for operator with attribute SUM(tag_bytes)
const option = screen.getByTitle(optionTestTitle);
expect(option).toBeInTheDocument();
await user.click(option);
// autocomplete input
expect(input).toHaveValue(constructedAttribute);
// clear value from input and write from keyboard
await user.clear(input);
await user.type(input, 'bytes');
// show again predefined options for operator with attribute SUM(tag_bytes)
const sameAttributeOption = screen.getByTitle(optionTestTitle);
expect(sameAttributeOption).toBeInTheDocument();
await user.click(sameAttributeOption);
expect(input).toHaveValue(constructedAttribute);
// show operators after SUM(tag_bytes)
const operatorsOptions = screen.getAllByTitle(optionTestTitle);
expect(operatorsOptions.length).toEqual(HAVING_OPERATORS.length);
// show operators after SUM(tag_bytes) when type from keyboard
await user.clear(input);
await user.type(input, `${constructedAttribute} !=`);
// get filtered operators
const filteredOperators = screen.getAllByTitle(optionTestTitle);
expect(filteredOperators.length).toEqual(1);
// clear and show again all operators
await user.clear(input);
await user.type(input, constructedAttribute);
const returnedOptions = screen.getAllByTitle(optionTestTitle);
expect(returnedOptions.length).toEqual(HAVING_OPERATORS.length);
// check write value after operator
await user.clear(input);
await user.type(input, `${constructedAttribute} != 123`);
expect(input).toHaveValue(`${constructedAttribute} != 123`);
const optionWithValue = screen.getByTitle(optionTestTitle);
// onChange after complete writting in the input or autocomplete
await user.click(optionWithValue);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([
transformFromStringToHaving(`${constructedAttribute} != 123`),
]);
// onChange with multiple operator
await user.type(input, `${constructedAttribute} IN 123 123`);
expect(input).toHaveValue(`${constructedAttribute} IN 123 123`);
const optionWithMultipleValue = screen.getByTitle(optionTestTitle);
await user.click(optionWithMultipleValue);
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenCalledWith([
transformFromStringToHaving(`${constructedAttribute} IN 123 123`),
]);
unmount();
});
});

View File

@ -0,0 +1 @@
export { HavingFilter } from './HavingFilter';

View File

@ -1,4 +1,5 @@
export { AggregatorFilter } from './AggregatorFilter'; export { AggregatorFilter } from './AggregatorFilter';
export { GroupByFilter } from './GroupByFilter'; export { GroupByFilter } from './GroupByFilter';
export { HavingFilter } from './HavingFilter';
export { OperatorsSelect } from './OperatorsSelect'; export { OperatorsSelect } from './OperatorsSelect';
export { ReduceToFilter } from './ReduceToFilter'; export { ReduceToFilter } from './ReduceToFilter';

View File

@ -0,0 +1,17 @@
type CreateNewBuilderItemNameParams = {
existNames: string[];
sourceNames: string[];
};
export const createNewBuilderItemName = ({
existNames,
sourceNames,
}: CreateNewBuilderItemNameParams): string => {
for (let i = 0; i < sourceNames.length; i += 1) {
if (!existNames.includes(sourceNames[i])) {
return sourceNames[i];
}
}
return '';
};

View File

@ -1,9 +0,0 @@
export const MAX_FORMULAS = 20;
const currentArray: string[] = Array.from(
Array(MAX_FORMULAS),
(_, i) => `F${i + 1}`,
);
export const createNewFormulaName = (index: number): string =>
currentArray[index];

View File

@ -1,14 +0,0 @@
export const MAX_QUERIES = 26;
const alpha: number[] = Array.from(Array(MAX_QUERIES), (_, i) => i + 65);
const alphabet: string[] = alpha.map((str) => String.fromCharCode(str));
export const createNewQueryName = (existNames: string[]): string => {
for (let i = 0; i < alphabet.length; i += 1) {
if (!existNames.includes(alphabet[i])) {
return alphabet[i];
}
}
return '';
};

View File

@ -0,0 +1,22 @@
import { OPERATORS } from 'constants/queryBuilder';
import { Having } from 'types/api/queryBuilder/queryBuilderData';
export const transformHavingToStringValue = (having: Having[]): string[] => {
const result: string[] = having.map((item) => {
const operator = Object.entries(OPERATORS).find(([key]) => key === item.op);
return `${item.columnName} ${operator ? operator[1] : ''} ${item.value.join(
' ',
)}`;
});
return result;
};
export const transformFromStringToHaving = (havingStr: string): Having => {
const [columnName, op, ...value] = havingStr.split(' ');
const operator = Object.entries(OPERATORS).find(([, value]) => value === op);
return { columnName, op: operator ? operator[0] : '', value };
};

View File

@ -1,13 +1,17 @@
// ** Helpers // ** Helpers
// ** Constants // ** Constants
import { import {
initialFormulaBuilderFormValues,
initialQueryBuilderFormValues, initialQueryBuilderFormValues,
mapOfOperators,
} from 'constants/queryBuilder'; } from 'constants/queryBuilder';
import { import {
createNewQueryName, alphabet,
formulasNames,
mapOfOperators,
MAX_FORMULAS,
MAX_QUERIES, MAX_QUERIES,
} from 'lib/newQueryBuilder/createNewQueryName'; } from 'constants/queryBuilder';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import React, { import React, {
createContext, createContext,
PropsWithChildren, PropsWithChildren,
@ -37,6 +41,7 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
setupInitialDataSource: () => {}, setupInitialDataSource: () => {},
removeEntityByIndex: () => {}, removeEntityByIndex: () => {},
addNewQuery: () => {}, addNewQuery: () => {},
addNewFormula: () => {},
}); });
const initialQueryBuilderData: QueryBuilderData = { const initialQueryBuilderData: QueryBuilderData = {
@ -93,12 +98,15 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQueryForm = { const newQuery: IBuilderQueryForm = {
...initialQueryBuilderFormValues, ...initialQueryBuilderFormValues,
queryName: createNewQueryName(existNames), queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
...(initialDataSource ...(initialDataSource
? { ? {
dataSource: initialDataSource, dataSource: initialDataSource,
aggregateOperator: mapOfOperators[initialDataSource][0], aggregateOperator: mapOfOperators[initialDataSource][0],
expression: createNewQueryName(existNames), expression: createNewBuilderItemName({
existNames,
sourceNames: alphabet,
}),
} }
: {}), : {}),
}; };
@ -108,6 +116,17 @@ export function QueryBuilderProvider({
[initialDataSource], [initialDataSource],
); );
const createNewFormula = useCallback((formulas: IBuilderFormula[]) => {
const existNames = formulas.map((item) => item.label);
const newFormula: IBuilderFormula = {
...initialFormulaBuilderFormValues,
label: createNewBuilderItemName({ existNames, sourceNames: formulasNames }),
};
return newFormula;
}, []);
const addNewQuery = useCallback(() => { const addNewQuery = useCallback(() => {
setQueryBuilderData((prevState) => { setQueryBuilderData((prevState) => {
if (prevState.queryData.length >= MAX_QUERIES) return prevState; if (prevState.queryData.length >= MAX_QUERIES) return prevState;
@ -118,6 +137,19 @@ export function QueryBuilderProvider({
}); });
}, [createNewQuery]); }, [createNewQuery]);
const addNewFormula = useCallback(() => {
setQueryBuilderData((prevState) => {
if (prevState.queryFormulas.length >= MAX_FORMULAS) return prevState;
const newFormula = createNewFormula(prevState.queryFormulas);
return {
...prevState,
queryFormulas: [...prevState.queryFormulas, newFormula],
};
});
}, [createNewFormula]);
const setupInitialDataSource = useCallback( const setupInitialDataSource = useCallback(
(newInitialDataSource: DataSource | null) => (newInitialDataSource: DataSource | null) =>
setInitialDataSource(newInitialDataSource), setInitialDataSource(newInitialDataSource),
@ -133,6 +165,12 @@ export function QueryBuilderProvider({
[], [],
); );
const updateFormulaBuilderData = useCallback(
(formulas: IBuilderFormula[], index: number, newFormula: IBuilderFormula) =>
formulas.map((item, idx) => (index === idx ? newFormula : item)),
[],
);
const handleSetQueryData = useCallback( const handleSetQueryData = useCallback(
(index: number, newQueryData: IBuilderQueryForm): void => { (index: number, newQueryData: IBuilderQueryForm): void => {
setQueryBuilderData((prevState) => { setQueryBuilderData((prevState) => {
@ -152,10 +190,22 @@ export function QueryBuilderProvider({
); );
const handleSetFormulaData = useCallback( const handleSetFormulaData = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
(index: number, formulaData: IBuilderFormula): void => {}, (index: number, formulaData: IBuilderFormula): void => {
[], setQueryBuilderData((prevState) => {
const updatedFormulasBuilderData = updateFormulaBuilderData(
prevState.queryFormulas,
index,
formulaData,
);
return {
...prevState,
queryFormulas: updatedFormulasBuilderData,
};
});
},
[updateFormulaBuilderData],
); );
console.log(queryBuilderData.queryData);
const contextValues: QueryBuilderContextType = useMemo( const contextValues: QueryBuilderContextType = useMemo(
() => ({ () => ({
@ -168,6 +218,7 @@ export function QueryBuilderProvider({
setupInitialDataSource, setupInitialDataSource,
removeEntityByIndex, removeEntityByIndex,
addNewQuery, addNewQuery,
addNewFormula,
}), }),
[ [
queryBuilderData, queryBuilderData,
@ -179,6 +230,7 @@ export function QueryBuilderProvider({
setupInitialDataSource, setupInitialDataSource,
removeEntityByIndex, removeEntityByIndex,
addNewQuery, addNewQuery,
addNewFormula,
], ],
); );

View File

@ -24,11 +24,11 @@ export interface TagFilter {
op: string; op: string;
} }
export interface Having { export type Having = {
key: string; columnName: string;
value: string;
op: string; op: string;
} value: string[];
};
// Type for query builder // Type for query builder
export type IBuilderQuery = { export type IBuilderQuery = {

View File

@ -149,4 +149,5 @@ export type QueryBuilderContextType = {
setupInitialDataSource: (newInitialDataSource: DataSource | null) => void; setupInitialDataSource: (newInitialDataSource: DataSource | null) => void;
removeEntityByIndex: (type: keyof QueryBuilderData, index: number) => void; removeEntityByIndex: (type: keyof QueryBuilderData, index: number) => void;
addNewQuery: () => void; addNewQuery: () => void;
addNewFormula: () => void;
}; };