Skip to content

Commit

Permalink
Merge pull request #100 from grafana/ivana/add-visual-query-builder
Browse files Browse the repository at this point in the history
Add VisualQueryBuilder
  • Loading branch information
ivanahuckova authored Oct 8, 2024
2 parents 32b6648 + 93b113a commit 3ba7740
Show file tree
Hide file tree
Showing 26 changed files with 2,253 additions and 19 deletions.
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@
"@changesets/read": "^0.5.9",
"@changesets/write": "^0.2.3",
"@grafana/experimental": "1.7.4",
"@hello-pangea/dnd": "^17.0.0",
"chance": "^1.1.7",
"fast-glob": "^3.3.1",
"lodash": "^4.17.21",
"memoize-one": "^5.1.1",
"prismjs": "^1.29.0",
"prompts": "^2.4.2",
"rc-cascader": "1.0.1",
"react-awesome-query-builder": "^5.3.1",
"react-popper-tooltip": "^4.4.2",
"react-use": "17.3.1",
"react-virtualized-auto-sizer": "^1.0.6",
"semver": "^7.5.4",
Expand Down Expand Up @@ -62,6 +65,7 @@
"@types/chance": "^1.1.0",
"@types/lodash": "^4.14.194",
"@types/memoize-one": "^5.1.2",
"@types/prismjs": "^1.26.4",
"@types/prompts": "^2.4.4",
"@types/react": "17.0.38",
"@types/react-calendar": "^3.1.2",
Expand All @@ -75,6 +79,7 @@
"mockdate": "^3.0.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select-event": "^5.5.1",
"rxjs": "^7.8.1",
"ts-jest": "^26.4.4",
"ts-node": "^10.9.1",
Expand Down
51 changes: 51 additions & 0 deletions src/components/VisualQueryBuilder/QueryModellerBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Registry } from '@grafana/data';

import { BINARY_OPERATIONS_KEY, VisualQuery, VisualQueryBinary, QueryBuilderLabelFilter, QueryBuilderOperation, VisualQueryModeller, QueryBuilderOperationDefinition } from './types';

export abstract class QueryModellerBase implements VisualQueryModeller {
protected operationsRegistry: Registry<QueryBuilderOperationDefinition>;
private categories: string[] = [];
innerQueryPlaceholder: string;

constructor(operationDefinitions: QueryBuilderOperationDefinition[], innerQueryPlaceholder?: string) {
this.operationsRegistry = new Registry<QueryBuilderOperationDefinition>(() => operationDefinitions);
this.innerQueryPlaceholder = innerQueryPlaceholder || '<query>';
}

protected setOperationCategories(categories: string[]) {
this.categories = categories;
}

abstract renderOperations(queryString: string, operations: QueryBuilderOperation[]): string;

abstract renderBinaryQueries(queryString: string, binaryQueries?: Array<VisualQueryBinary<VisualQuery>>): string

abstract renderLabels(labels: QueryBuilderLabelFilter[]): string;

abstract renderQuery(query: VisualQuery, nested?: boolean): string

getOperationsForCategory(category: string) {
return this.operationsRegistry.list().filter((op) => op.category === category && !op.hideFromList);
}

getAlternativeOperations(key: string) {
return this.operationsRegistry.list().filter((op) => op.alternativesKey && op.alternativesKey === key);
}

getCategories() {
return this.categories;
}

getOperationDefinition(id: string): QueryBuilderOperationDefinition | undefined {
return this.operationsRegistry.getIfExists(id);
}

hasBinaryOp(query: VisualQuery): boolean {
return (
query.operations.find((op) => {
const def = this.getOperationDefinition(op.id);
return def?.category === BINARY_OPERATIONS_KEY;
}) !== undefined
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { isConflictingLabelFilter } from "./LabelFilterItem";

describe('isConflictingSelector', () => {
it('returns true if selector is conflicting', () => {
const newLabel = { label: 'job', op: '!=', value: 'tns/app' };
const labels = [
{ label: 'job', op: '=', value: 'tns/app' },
{ label: 'job', op: '!=', value: 'tns/app' },
];
expect(isConflictingLabelFilter(newLabel, labels)).toBe(true);
});

it('returns false if selector is not complete', () => {
const newLabel = { label: 'job', op: '', value: 'tns/app' };
const labels = [
{ label: 'job', op: '=', value: 'tns/app' },
{ label: 'job', op: '', value: 'tns/app' },
];
expect(isConflictingLabelFilter(newLabel, labels)).toBe(false);
});

it('returns false if selector is not conflicting', () => {
const newLabel = { label: 'host', op: '=', value: 'docker-desktop' };
const labels = [
{ label: 'job', op: '=', value: 'tns/app' },
{ label: 'host', op: '=', value: 'docker-desktop' },
];
expect(isConflictingLabelFilter(newLabel, labels)).toBe(false);
});
});
224 changes: 224 additions & 0 deletions src/components/VisualQueryBuilder/components/LabelFilterItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { uniqBy } from 'lodash';
import React, { useRef, useState } from 'react';
import { v4 } from 'uuid';

import { SelectableValue, toOption } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { InlineField, Select } from '@grafana/ui';

import { QueryBuilderLabelFilter } from '../types';
import { InputGroup } from '../../QueryEditor/InputGroup';
import { AccessoryButton } from '../../QueryEditor/AccessoryButton';

const CONFLICTING_LABEL_FILTER_ERROR_MESSAGE = 'You have conflicting label filters';
interface Props {
defaultOp: string;
item: Partial<QueryBuilderLabelFilter>;
items: Array<Partial<QueryBuilderLabelFilter>>;
onChange: (value: Partial<QueryBuilderLabelFilter>) => void;
onGetLabelNames: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<Array<SelectableValue<string>>>;
onGetLabelValues: (forLabel: Partial<QueryBuilderLabelFilter>) => Promise<Array<SelectableValue<string>>>;
onDelete: () => void;
invalidLabel?: boolean;
invalidValue?: boolean;
multiValueSeparator?: string;
}

export function LabelFilterItem({
item,
items,
defaultOp,
onChange,
onDelete,
onGetLabelNames,
onGetLabelValues,
invalidLabel,
invalidValue,
multiValueSeparator = "|",
}: Props) {
const [state, setState] = useState<{
labelNames?: Array<SelectableValue<string>>;
labelValues?: Array<SelectableValue<string>>;
isLoadingLabelNames?: boolean;
isLoadingLabelValues?: boolean;
}>({});
// there's a bug in react-select where the menu doesn't recalculate its position when the options are loaded asynchronously
// see https://github.com/grafana/grafana/issues/63558
// instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded
const [labelNamesMenuOpen, setLabelNamesMenuOpen] = useState(false);
const [labelValuesMenuOpen, setLabelValuesMenuOpen] = useState(false);

const isMultiSelect = (operator = item.op) => {
return operators.find((op) => op.label === operator)?.isMultiValue;
};

const getSelectOptionsFromString = (item?: string): string[] => {
if (item) {
if (item.indexOf(multiValueSeparator) > 0) {
return item.split(multiValueSeparator);
}
return [item];
}
return [];
};

const getOptions = (): Array<SelectableValue<string>> => {
const labelValues = state.labelValues ? [...state.labelValues] : [];
const selectedOptions = getSelectOptionsFromString(item?.value).map(toOption);

// Remove possible duplicated values
return uniqBy([...selectedOptions, ...labelValues], 'value');
};

const isConflicting = isConflictingLabelFilter(item, items);
const { current: id } = useRef(v4());

return (
<div data-testid="visual-query-builder-dimensions-filter-item">
<InlineField error={CONFLICTING_LABEL_FILTER_ERROR_MESSAGE} invalid={isConflicting ? true : undefined}>
<InputGroup>
<Select<string>
placeholder="Select label"
data-testid={selectors.components.QueryBuilder.labelSelect}
inputId={`visual-query-builder-dimensions-filter-item-key-${id}`}
width="auto"
value={item.label ? toOption(item.label) : null}
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelNames: true });
const labelNames = await onGetLabelNames(item);
setLabelNamesMenuOpen(true);
setState({ labelNames, isLoadingLabelNames: undefined });
}}
onCloseMenu={() => {
setLabelNamesMenuOpen(false);
}}
isOpen={labelNamesMenuOpen}
isLoading={state.isLoadingLabelNames}
options={state.labelNames}
onChange={(change) => {
if (change.value) {
onChange({
...item,
op: item.op ?? defaultOp,
label: change.value,
});
}
}}
invalid={isConflicting || invalidLabel}
/>

<Select<string>
data-testid={selectors.components.QueryBuilder.matchOperatorSelect}
value={toOption(item.op ?? defaultOp)}
options={operators}
width="auto"
onChange={(change) => {
if (change.value) {
onChange({
...item,
op: change.value,
value: isMultiSelect(change.value) ? item.value : getSelectOptionsFromString(item?.value)[0],
});
}
}}
invalid={isConflicting}
/>

<Select<string>
placeholder="Select value"
data-testid={selectors.components.QueryBuilder.valueSelect}
inputId={`visual-query-builder-dimensions-filter-item-value-${id}`}
width="auto"
value={
isMultiSelect()
? getSelectOptionsFromString(item?.value).map(toOption)
: getSelectOptionsFromString(item?.value).map(toOption)[0]
}
allowCustomValue
onOpenMenu={async () => {
setState({ isLoadingLabelValues: true });
const labelValues = await onGetLabelValues(item);
setState({
...state,
labelValues,
isLoadingLabelValues: undefined,
});
setLabelValuesMenuOpen(true);
}}
onCloseMenu={() => {
setLabelValuesMenuOpen(false);
}}
isOpen={labelValuesMenuOpen}
isMulti={isMultiSelect()}
isLoading={state.isLoadingLabelValues}
options={getOptions()}
onChange={(change) => {
if (change.value) {
onChange({
...item,
value: change.value,
op: item.op ?? defaultOp,
});
} else {
// otherwise, we're dealing with a multi-value select which is array of options
const changes = change
.map((change: SelectableValue<string>) => {
if (change.value) {
return change.value;
} else {
return undefined
}
})
.filter((val: string | undefined) => val !== undefined)
.join(multiValueSeparator);
onChange({ ...item, value: changes, op: item.op ?? defaultOp });
}
}}
invalid={isConflicting || invalidValue}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
</InlineField>
</div>
);
}

const operators = [
{ label: '=', value: '=', description: 'Equals', isMultiValue: false },
{ label: '!=', value: '!=', description: 'Does not equal', isMultiValue: false },
{ label: '=~', value: '=~', description: 'Matches regex', isMultiValue: true },
{ label: '!~', value: '!~', description: 'Does not match regex', isMultiValue: true },
]


export function isConflictingLabelFilter(
newLabel: Partial<QueryBuilderLabelFilter>,
labels: Array<Partial<QueryBuilderLabelFilter>>
): boolean {
if (!newLabel.label || !newLabel.op || !newLabel.value) {
return false;
}

if (labels.length < 2) {
return false;
}

const operationIsNegative = newLabel.op.toString().startsWith('!');

const candidates = labels.filter(
(label) => label.label === newLabel.label && label.value === newLabel.value && label.op !== newLabel.op
);

const conflict = candidates.some((candidate) => {
if (operationIsNegative && candidate?.op?.toString().startsWith('!') === false) {
return true;
}
if (operationIsNegative === false && candidate?.op?.toString().startsWith('!')) {
return true;
}
return false;
});

return conflict;
}
Loading

0 comments on commit 3ba7740

Please sign in to comment.