mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-11 16:09:03 +08:00
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:
parent
63570c847a
commit
dd25ad95c7
@ -1,9 +1,11 @@
|
||||
// ** TODO: use it for creating formula names
|
||||
// import { createNewFormulaName } from 'lib/newQueryBuilder/createNewFormulaName';
|
||||
// ** Helpers
|
||||
import { createNewQueryName } from 'lib/newQueryBuilder/createNewQueryName';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { LocalDataType } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQueryForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
Having,
|
||||
IBuilderFormula,
|
||||
IBuilderQueryForm,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
BoolOperators,
|
||||
DataSource,
|
||||
@ -14,6 +16,16 @@ import {
|
||||
TracesAggregatorOperator,
|
||||
} 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 {
|
||||
GET_AGGREGATE_ATTRIBUTE = 'GET_AGGREGATE_ATTRIBUTE',
|
||||
GET_AGGREGATE_KEYS = 'GET_AGGREGATE_KEYS',
|
||||
@ -27,9 +39,15 @@ export const mapOfOperators: Record<DataSource, string[]> = {
|
||||
|
||||
export const mapOfFilters: Record<DataSource, string[]> = {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
metrics: ['Having', 'Aggregation interval'],
|
||||
logs: ['Limit', 'Having', 'Order by', 'Aggregation interval'],
|
||||
traces: ['Limit', 'Having', 'Order by', 'Aggregation interval'],
|
||||
metrics: ['Aggregation interval', 'Having'],
|
||||
logs: ['Order by', 'Limit', 'Having', 'Aggregation interval'],
|
||||
traces: ['Order by', 'Limit', 'Having', 'Aggregation interval'],
|
||||
};
|
||||
|
||||
export const initialHavingValues: Having = {
|
||||
columnName: '',
|
||||
op: '',
|
||||
value: [],
|
||||
};
|
||||
|
||||
export const initialAggregateAttribute: IBuilderQueryForm['aggregateAttribute'] = {
|
||||
@ -41,7 +59,7 @@ export const initialAggregateAttribute: IBuilderQueryForm['aggregateAttribute']
|
||||
|
||||
export const initialQueryBuilderFormValues: IBuilderQueryForm = {
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: createNewQueryName([]),
|
||||
queryName: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
|
||||
aggregateOperator: Object.values(MetricAggregateOperator)[0],
|
||||
aggregateAttribute: initialAggregateAttribute,
|
||||
tagFilters: { items: [], op: 'AND' },
|
||||
@ -56,6 +74,16 @@ export const initialQueryBuilderFormValues: IBuilderQueryForm = {
|
||||
reduceTo: '',
|
||||
};
|
||||
|
||||
export const initialFormulaBuilderFormValues: IBuilderFormula = {
|
||||
label: createNewBuilderItemName({
|
||||
existNames: [],
|
||||
sourceNames: formulasNames,
|
||||
}),
|
||||
expression: '',
|
||||
disabled: false,
|
||||
legend: '',
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
@ -136,3 +164,14 @@ export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
|
||||
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,
|
||||
];
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Col, Row } from 'antd';
|
||||
import { MAX_FORMULAS, MAX_QUERIES } from 'constants/queryBuilder';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/useQueryBuilder';
|
||||
import { MAX_FORMULAS } from 'lib/newQueryBuilder/createNewFormulaName';
|
||||
// ** Constants
|
||||
import { MAX_QUERIES } from 'lib/newQueryBuilder/createNewQueryName';
|
||||
import React, { memo, useEffect, useMemo } from 'react';
|
||||
|
||||
// ** Components
|
||||
import { Query } from './components';
|
||||
import { Formula, Query } from './components';
|
||||
// ** Types
|
||||
import { QueryBuilderProps } from './QueryBuilder.interfaces';
|
||||
// ** Styles
|
||||
@ -21,6 +20,7 @@ export const QueryBuilder = memo(function QueryBuilder({
|
||||
queryBuilderData,
|
||||
setupInitialDataSource,
|
||||
addNewQuery,
|
||||
addNewFormula,
|
||||
} = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
@ -39,7 +39,7 @@ export const QueryBuilder = memo(function QueryBuilder({
|
||||
);
|
||||
|
||||
const isDisabledFormulaButton = useMemo(
|
||||
() => queryBuilderData.queryData.length >= MAX_FORMULAS,
|
||||
() => queryBuilderData.queryFormulas.length >= MAX_FORMULAS,
|
||||
[queryBuilderData],
|
||||
);
|
||||
|
||||
@ -58,6 +58,11 @@ export const QueryBuilder = memo(function QueryBuilder({
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
{queryBuilderData.queryFormulas.map((formula, index) => (
|
||||
<Col key={formula.label} span={24}>
|
||||
<Formula formula={formula} index={index} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
@ -75,6 +80,7 @@ export const QueryBuilder = memo(function QueryBuilder({
|
||||
<Col>
|
||||
<Button
|
||||
disabled={isDisabledFormulaButton}
|
||||
onClick={addNewFormula}
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { MinusSquareOutlined, PlusSquareOutlined } from '@ant-design/icons';
|
||||
import { Typography } from 'antd';
|
||||
import { Col, Typography } from 'antd';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const IconCss = css`
|
||||
@ -15,14 +15,8 @@ export const StyledIconClose = styled(MinusSquareOutlined)`
|
||||
${IconCss}
|
||||
`;
|
||||
|
||||
export const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
export const StyledInner = styled(Col)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
export const StyledInner = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.875rem;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Col, Row } from 'antd';
|
||||
import React, { Fragment, memo, ReactNode, useState } from 'react';
|
||||
|
||||
// ** Types
|
||||
@ -39,12 +40,14 @@ export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<StyledInner onClick={handleToggleOpenFilters}>
|
||||
{isOpenedFilters ? <StyledIconClose /> : <StyledIconOpen />}
|
||||
{!isOpenedFilters && <span>Add conditions for {filtersTexts}</span>}
|
||||
</StyledInner>
|
||||
{isOpenedFilters && children}
|
||||
</div>
|
||||
</Col>
|
||||
{isOpenedFilters && <Col span={24}>{children}</Col>}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
@ -2,8 +2,8 @@ import styled from 'styled-components';
|
||||
|
||||
export const StyledLabel = styled.div`
|
||||
padding: 0 0.6875rem;
|
||||
width: fit-content;
|
||||
min-height: 2rem;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
|
@ -1,2 +1,3 @@
|
||||
// TODO: temporary type
|
||||
export type FormulaProps = { test: string };
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type FormulaProps = { formula: IBuilderFormula; index: number };
|
||||
|
@ -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 {
|
||||
return <div>null</div>;
|
||||
// ** Types
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
export type ListItemWrapperProps = PropsWithChildren & { onDelete: () => void };
|
@ -4,7 +4,7 @@ import styled from 'styled-components';
|
||||
|
||||
export const StyledDeleteEntity = styled(CloseCircleOutlined)`
|
||||
position: absolute;
|
||||
top: 0.9375rem;
|
||||
top: 0.5rem;
|
||||
right: 0.9375rem;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ListItemWrapper } from './ListItemWrapper';
|
@ -5,7 +5,7 @@ export type ListMarkerProps = {
|
||||
labelName: string;
|
||||
index: number;
|
||||
className?: string;
|
||||
isAvailableToDisable: boolean;
|
||||
toggleDisabled: (index: number) => void;
|
||||
isAvailableToDisable?: boolean;
|
||||
onDisable: (index: number) => void;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
@ -11,16 +11,16 @@ export const ListMarker = memo(function ListMarker({
|
||||
isDisabled,
|
||||
labelName,
|
||||
index,
|
||||
isAvailableToDisable,
|
||||
isAvailableToDisable = true,
|
||||
className,
|
||||
toggleDisabled,
|
||||
onDisable,
|
||||
style,
|
||||
}: ListMarkerProps): JSX.Element {
|
||||
const buttonProps: Partial<ButtonProps> = isAvailableToDisable
|
||||
? {
|
||||
type: isDisabled ? 'default' : 'primary',
|
||||
icon: isDisabled ? <EyeInvisibleFilled /> : <EyeFilled />,
|
||||
onClick: (): void => toggleDisabled(index),
|
||||
onClick: (): void => onDisable(index),
|
||||
}
|
||||
: { type: 'primary' };
|
||||
|
||||
|
@ -11,11 +11,13 @@ import {
|
||||
AdditionalFiltersToggler,
|
||||
DataSourceDropdown,
|
||||
FilterLabel,
|
||||
ListItemWrapper,
|
||||
ListMarker,
|
||||
} from 'container/QueryBuilder/components';
|
||||
import {
|
||||
AggregatorFilter,
|
||||
GroupByFilter,
|
||||
HavingFilter,
|
||||
OperatorsSelect,
|
||||
ReduceToFilter,
|
||||
} from 'container/QueryBuilder/filters';
|
||||
@ -29,16 +31,15 @@ import { findDataTypeOfOperator } from 'lib/query/findDataTypeOfOperator';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
Having,
|
||||
IBuilderQueryForm,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { transformToUpperCase } from 'utils/transformToUpperCase';
|
||||
|
||||
// ** Types
|
||||
import { QueryProps } from './Query.interfaces';
|
||||
// ** Styles
|
||||
import { StyledDeleteEntity, StyledFilterRow, StyledRow } from './Query.styled';
|
||||
|
||||
export const Query = memo(function Query({
|
||||
index,
|
||||
@ -116,6 +117,7 @@ export const Query = memo(function Query({
|
||||
const newQuery: IBuilderQueryForm = {
|
||||
...query,
|
||||
aggregateAttribute: value,
|
||||
having: [],
|
||||
};
|
||||
|
||||
handleSetQueryData(index, newQuery);
|
||||
@ -192,6 +194,15 @@ export const Query = memo(function Query({
|
||||
[index, query, handleSetQueryData],
|
||||
);
|
||||
|
||||
const handleChangeHavingFilter = useCallback(
|
||||
(having: Having[]) => {
|
||||
const newQuery: IBuilderQueryForm = { ...query, having };
|
||||
|
||||
handleSetQueryData(index, newQuery);
|
||||
},
|
||||
[index, query, handleSetQueryData],
|
||||
);
|
||||
|
||||
const handleDeleteQuery = useCallback(() => {
|
||||
removeEntityByIndex('queryData', index);
|
||||
}, [removeEntityByIndex, index]);
|
||||
@ -246,14 +257,13 @@ export const Query = memo(function Query({
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRow gutter={[0, 15]}>
|
||||
<StyledDeleteEntity onClick={handleDeleteQuery} />
|
||||
<ListItemWrapper onDelete={handleDeleteQuery}>
|
||||
<Col span={24}>
|
||||
<Row align="middle" justify="space-between">
|
||||
<Row align="middle">
|
||||
<Col>
|
||||
<ListMarker
|
||||
isDisabled={query.disabled}
|
||||
toggleDisabled={handleToggleDisableQuery}
|
||||
onDisable={handleToggleDisableQuery}
|
||||
labelName={query.queryName}
|
||||
index={index}
|
||||
isAvailableToDisable={isAvailableToDisable}
|
||||
@ -267,13 +277,21 @@ export const Query = memo(function Query({
|
||||
) : (
|
||||
<FilterLabel label={transformToUpperCase(query.dataSource)} />
|
||||
)}
|
||||
{isMatricsDataSource && <FilterLabel label="WHERE" />}
|
||||
</Col>
|
||||
<Col span={isMatricsDataSource ? 17 : 20}>
|
||||
<Col flex="1">
|
||||
<Row gutter={[11, 5]}>
|
||||
{isMatricsDataSource && (
|
||||
<Col>
|
||||
<FilterLabel label="WHERE" />
|
||||
</Col>
|
||||
)}
|
||||
<Col flex="1">
|
||||
<QueryBuilderSearch query={query} onChange={handleChangeTagFilters} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
@ -291,6 +309,7 @@ export const Query = memo(function Query({
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
<Col span={11} offset={2}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
@ -307,33 +326,58 @@ export const Query = memo(function Query({
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<AdditionalFiltersToggler listOfAdditionalFilter={listOfAdditionalFilters}>
|
||||
<Row gutter={[0, 11]} justify="space-between">
|
||||
{!isMatricsDataSource && (
|
||||
<StyledFilterRow gutter={[11, 5]} justify="space-around">
|
||||
<Col span={2}>
|
||||
<FilterLabel label="Order by" />
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<OrderByFilter query={query} onChange={handleChangeOrderByKeys} />
|
||||
</Col>
|
||||
<Col span={1.5}>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Limit" />
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<LimitFilter query={query} onChange={handleChangeLimit} />
|
||||
</Col>
|
||||
</StyledFilterRow>
|
||||
</Row>
|
||||
</Col>
|
||||
)}
|
||||
{query.aggregateOperator !== StringOperators.NOOP && (
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col span={3}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="HAVING" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<HavingFilter onChange={handleChangeHavingFilter} query={query} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
)}
|
||||
{!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 span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Aggregate Every" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Col flex="1 1 6rem">
|
||||
<AggregateEveryFilter
|
||||
query={query}
|
||||
onChange={handleChangeAggregateEvery}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</AdditionalFiltersToggler>
|
||||
</Col>
|
||||
<Row style={{ width: '100%' }}>
|
||||
@ -344,6 +388,6 @@ export const Query = memo(function Query({
|
||||
addonBefore="Legend Format"
|
||||
/>
|
||||
</Row>
|
||||
</StyledRow>
|
||||
</ListItemWrapper>
|
||||
);
|
||||
});
|
||||
|
@ -2,5 +2,6 @@ export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
export { DataSourceDropdown } from './DataSourceDropdown';
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
export { Formula } from './Formula';
|
||||
export { ListItemWrapper } from './ListItemWrapper';
|
||||
export { ListMarker } from './ListMarker';
|
||||
export { Query } from './Query';
|
||||
|
@ -0,0 +1,9 @@
|
||||
import {
|
||||
Having,
|
||||
IBuilderQueryForm,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type HavingFilterProps = {
|
||||
query: IBuilderQueryForm;
|
||||
onChange: (having: Having[]) => void;
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -0,0 +1 @@
|
||||
export { HavingFilter } from './HavingFilter';
|
@ -1,4 +1,5 @@
|
||||
export { AggregatorFilter } from './AggregatorFilter';
|
||||
export { GroupByFilter } from './GroupByFilter';
|
||||
export { HavingFilter } from './HavingFilter';
|
||||
export { OperatorsSelect } from './OperatorsSelect';
|
||||
export { ReduceToFilter } from './ReduceToFilter';
|
||||
|
17
frontend/src/lib/newQueryBuilder/createNewBuilderItemName.ts
Normal file
17
frontend/src/lib/newQueryBuilder/createNewBuilderItemName.ts
Normal 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 '';
|
||||
};
|
@ -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];
|
@ -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 '';
|
||||
};
|
22
frontend/src/lib/query/transformQueryBuilderData.ts
Normal file
22
frontend/src/lib/query/transformQueryBuilderData.ts
Normal 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 };
|
||||
};
|
@ -1,13 +1,17 @@
|
||||
// ** Helpers
|
||||
// ** Constants
|
||||
import {
|
||||
initialFormulaBuilderFormValues,
|
||||
initialQueryBuilderFormValues,
|
||||
mapOfOperators,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
createNewQueryName,
|
||||
alphabet,
|
||||
formulasNames,
|
||||
mapOfOperators,
|
||||
MAX_FORMULAS,
|
||||
MAX_QUERIES,
|
||||
} from 'lib/newQueryBuilder/createNewQueryName';
|
||||
} from 'constants/queryBuilder';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import React, {
|
||||
createContext,
|
||||
PropsWithChildren,
|
||||
@ -37,6 +41,7 @@ export const QueryBuilderContext = createContext<QueryBuilderContextType>({
|
||||
setupInitialDataSource: () => {},
|
||||
removeEntityByIndex: () => {},
|
||||
addNewQuery: () => {},
|
||||
addNewFormula: () => {},
|
||||
});
|
||||
|
||||
const initialQueryBuilderData: QueryBuilderData = {
|
||||
@ -93,12 +98,15 @@ export function QueryBuilderProvider({
|
||||
|
||||
const newQuery: IBuilderQueryForm = {
|
||||
...initialQueryBuilderFormValues,
|
||||
queryName: createNewQueryName(existNames),
|
||||
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
|
||||
...(initialDataSource
|
||||
? {
|
||||
dataSource: initialDataSource,
|
||||
aggregateOperator: mapOfOperators[initialDataSource][0],
|
||||
expression: createNewQueryName(existNames),
|
||||
expression: createNewBuilderItemName({
|
||||
existNames,
|
||||
sourceNames: alphabet,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
@ -108,6 +116,17 @@ export function QueryBuilderProvider({
|
||||
[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(() => {
|
||||
setQueryBuilderData((prevState) => {
|
||||
if (prevState.queryData.length >= MAX_QUERIES) return prevState;
|
||||
@ -118,6 +137,19 @@ export function QueryBuilderProvider({
|
||||
});
|
||||
}, [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(
|
||||
(newInitialDataSource: DataSource | null) =>
|
||||
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(
|
||||
(index: number, newQueryData: IBuilderQueryForm): void => {
|
||||
setQueryBuilderData((prevState) => {
|
||||
@ -152,10 +190,22 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
const handleSetFormulaData = useCallback(
|
||||
// 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(
|
||||
() => ({
|
||||
@ -168,6 +218,7 @@ export function QueryBuilderProvider({
|
||||
setupInitialDataSource,
|
||||
removeEntityByIndex,
|
||||
addNewQuery,
|
||||
addNewFormula,
|
||||
}),
|
||||
[
|
||||
queryBuilderData,
|
||||
@ -179,6 +230,7 @@ export function QueryBuilderProvider({
|
||||
setupInitialDataSource,
|
||||
removeEntityByIndex,
|
||||
addNewQuery,
|
||||
addNewFormula,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -24,11 +24,11 @@ export interface TagFilter {
|
||||
op: string;
|
||||
}
|
||||
|
||||
export interface Having {
|
||||
key: string;
|
||||
value: string;
|
||||
export type Having = {
|
||||
columnName: string;
|
||||
op: string;
|
||||
}
|
||||
value: string[];
|
||||
};
|
||||
|
||||
// Type for query builder
|
||||
export type IBuilderQuery = {
|
||||
|
@ -149,4 +149,5 @@ export type QueryBuilderContextType = {
|
||||
setupInitialDataSource: (newInitialDataSource: DataSource | null) => void;
|
||||
removeEntityByIndex: (type: keyof QueryBuilderData, index: number) => void;
|
||||
addNewQuery: () => void;
|
||||
addNewFormula: () => void;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user