Skip to content

Commit

Permalink
AdHocFilters: Add support for new isOneOf multi value operator (#868)
Browse files Browse the repository at this point in the history
  • Loading branch information
ashharrison90 authored Aug 29, 2024
1 parent 0329d47 commit 1eacb00
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 88 deletions.
5 changes: 5 additions & 0 deletions packages/scenes-app/src/demos/adhocFiltersDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function getAdhocFiltersDemo(defaults: SceneAppPageState) {
// Only want keys for this series
baseFilters: [{ key: '__name__', operator: '=', value: 'ALERTS', condition: '' }],
datasource: { uid: 'gdev-prometheus' },
supportsMultiValueOperators: true,
}),
],
}),
Expand Down Expand Up @@ -77,6 +78,7 @@ export function getAdhocFiltersDemo(defaults: SceneAppPageState) {
applyMode: 'manual',
datasource: { uid: 'gdev-prometheus' },
filters: [{ key: 'job', operator: '=', value: 'grafana', condition: '' }],
supportsMultiValueOperators: true,
});

return new EmbeddedScene({
Expand Down Expand Up @@ -138,6 +140,7 @@ export function getAdhocFiltersDemo(defaults: SceneAppPageState) {
hide: VariableHide.hideLabel,
datasource: { uid: 'gdev-prometheus' },
filters: [{ key: 'job', operator: '=', value: 'has no text', condition: '' }],
supportsMultiValueOperators: true,
}),
new AdHocFiltersVariable({
name: 'button-text',
Expand All @@ -147,6 +150,7 @@ export function getAdhocFiltersDemo(defaults: SceneAppPageState) {
addFilterButtonText: 'Add a filter',
datasource: { uid: 'gdev-prometheus' },
filters: [{ key: 'job', operator: '=', value: 'has text on add button', condition: '' }],
supportsMultiValueOperators: true,
}),

new AdHocFiltersVariable({
Expand All @@ -157,6 +161,7 @@ export function getAdhocFiltersDemo(defaults: SceneAppPageState) {
addFilterButtonText: 'Filter',
datasource: { uid: 'gdev-prometheus' },
filters: [{ key: 'job', operator: '=', value: 'also has text on add button', condition: '' }],
supportsMultiValueOperators: true,
}),
],
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/scenes/src/querying/SceneQueryRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ describe.each(['11.1.2', '11.1.1'])('SceneQueryRunner', (v) => {
expect(runRequestCall[1].filters).toEqual(filtersVar.state.filters);

// Verify updating filter re-triggers query
filtersVar._updateFilter(filtersVar.state.filters[0], 'value', { value: 'newValue' });
filtersVar._updateFilter(filtersVar.state.filters[0], { value: 'newValue' });

await new Promise((r) => setTimeout(r, 1));

Expand Down Expand Up @@ -577,7 +577,7 @@ describe.each(['11.1.2', '11.1.1'])('SceneQueryRunner', (v) => {
]);

// Verify updating filter re-triggers query
filtersVar._updateFilter(filtersVar.state.filters[1], 'value', { value: 'D' });
filtersVar._updateFilter(filtersVar.state.filters[1], { value: 'D' });

await new Promise((r) => setTimeout(r, 1));

Expand Down
127 changes: 105 additions & 22 deletions packages/scenes/src/variables/adhoc/AdHocFilterRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { useMemo, useState } from 'react';

import { AdHocFiltersVariable, AdHocFilterWithLabels } from './AdHocFiltersVariable';
import { AdHocFiltersVariable, AdHocFilterWithLabels, isMultiValueOperator } from './AdHocFiltersVariable';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Field, InputActionMeta, Select, useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { ControlsLabel } from '../../utils/ControlsLabel';
import { getAdhocOptionSearcher } from './getAdhocOptionSearcher';
import { handleOptionGroups } from '../utils';
import { OptionWithCheckbox } from '../components/VariableValueSelect';

interface Props {
filter: AdHocFilterWithLabels;
Expand All @@ -33,11 +34,17 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
const [isValuesLoading, setIsValuesLoading] = useState(false);
const [isKeysOpen, setIsKeysOpen] = useState(false);
const [isValuesOpen, setIsValuesOpen] = useState(false);
const [isOperatorOpen, setIsOperatorOpen] = useState(false);
const [valueInputValue, setValueInputValue] = useState('');
const [valueHasCustomValue, setValueHasCustomValue] = useState(false);
// To not trigger queries on every selection we store this state locally here and only update the variable onBlur
// TODO remove expect-error when we're on the latest version of @grafana/data
// @ts-expect-error
const [uncommittedValue, setUncommittedValue] = useState<SelectableValue>(filter.values ? filter.values.map((value, index) => keyLabelToOption(value, filter.valueLabels?.[index])) : []);
const isMultiValue = isMultiValueOperator(filter.operator);

const keyValue = keyLabelToOption(filter.key, filter.keyLabel);
const valueValue = keyLabelToOption(filter.value, filter.valueLabel);
const valueValue = keyLabelToOption(filter.value, filter.valueLabels?.[0]);

const optionSearcher = useMemo(() => getAdhocOptionSearcher(values), [values]);

Expand All @@ -48,11 +55,64 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
return value;
};

const onOperatorChange = (v: SelectableValue) => {
const existingOperator = filter.operator;
const newOperator = v.value;

const update: Partial<AdHocFilterWithLabels> = { operator: newOperator };
// clear value if operator has changed from multi to single
if (isMultiValueOperator(existingOperator) && !isMultiValueOperator(newOperator)) {
update.value = '';
update.valueLabels = [''];
// TODO remove expect-error when we're on the latest version of @grafana/data
// @ts-expect-error
update.values = undefined;
setUncommittedValue([]);
// set values if operator has changed from single to multi
} else if (!isMultiValueOperator(existingOperator) && isMultiValueOperator(newOperator) && filter.value) {
// TODO remove expect-error when we're on the latest version of @grafana/data
// @ts-expect-error
update.values = [filter.value];
setUncommittedValue([{
value: filter.value,
label: filter.valueLabels?.[0] ?? filter.value,
}]);
}
model._updateFilter(filter, update);
}

const filteredValueOptions = useMemo(
() => handleOptionGroups(optionSearcher(valueInputValue)),
[optionSearcher, valueInputValue]
);

const multiValueProps = {
isMulti: true,
value: uncommittedValue,
components: {
Option: OptionWithCheckbox,
},
hideSelectedOptions: false,
closeMenuOnSelect: false,
openMenuOnFocus: false,
onChange: (v: SelectableValue) => {
setUncommittedValue(v);
// clear input value when creating a new custom multi value
if (v.some((value: SelectableValue) => value.__isNew__)) {
setValueInputValue('');
}
},
onBlur: () => {
model._updateFilter(filter, {
value: uncommittedValue[0]?.value ?? '',
// TODO remove expect-error when we're on the latest version of @grafana/data
// @ts-expect-error
values: uncommittedValue.map((option: SelectableValue<string>) => option.value),
valueLabels: uncommittedValue.map((option: SelectableValue<string>) => option.label),
});
}
}

const valueSelect = (
<Select
virtualized
Expand All @@ -61,7 +121,7 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
allowCreateWhileLoading
formatCreateLabel={(inputValue) => `Use custom value: ${inputValue}`}
disabled={model.state.readOnly}
className={cx(styles.value, isKeysOpen ? styles.widthWhenOpen : undefined)}
className={cx(styles.value, isValuesOpen ? styles.widthWhenOpen : undefined)}
width="auto"
value={valueValue}
filterOption={filterNoOp}
Expand All @@ -70,7 +130,10 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
inputValue={valueInputValue}
onInputChange={onValueInputChange}
onChange={(v) => {
model._updateFilter(filter, 'value', v);
model._updateFilter(filter, {
value: v.value,
valueLabels: v.label ? [v.label] : [v.value]
});

if (valueHasCustomValue !== v.__isNew__) {
setValueHasCustomValue(v.__isNew__);
Expand All @@ -81,7 +144,6 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
// instead, we explicitly control the menu visibility and prevent showing it until the options have fully loaded
isOpen={isValuesOpen && !isValuesLoading}
isLoading={isValuesLoading}
autoFocus={filter.key !== '' && filter.value === ''}
openMenuOnFocus={true}
onOpenMenu={async () => {
setIsValuesLoading(true);
Expand All @@ -97,6 +159,7 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
setIsValuesOpen(false);
setValueInputValue('');
}}
{...(isMultiValue && multiValueProps)}
/>
);

Expand All @@ -112,7 +175,19 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
value={keyValue}
placeholder={'Select label'}
options={handleOptionGroups(keys)}
onChange={(v) => model._updateFilter(filter, 'key', v)}
onChange={(v) => {
model._updateFilter(filter, {
key: v.value,
keyLabel: v.label,
// clear value if key has changed
value: '',
valueLabels: [''],
// TODO remove expect-error when we're on the latest version of @grafana/data
// @ts-expect-error
values: undefined
})
setUncommittedValue([]);
}}
autoFocus={filter.key === ''}
// 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
Expand All @@ -138,6 +213,24 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
/>
);

const operatorSelect = (
<Select
className={cx(styles.operator, {
[styles.widthWhenOpen]: isOperatorOpen,
})}
value={filter.operator}
disabled={model.state.readOnly}
options={model._getOperators()}
onChange={onOperatorChange}
onOpenMenu={() => {
setIsOperatorOpen(true)
}}
onCloseMenu={() => {
setIsOperatorOpen(false)
}}
/>
);

if (model.state.layout === 'vertical') {
if (filter.key) {
const label = (
Expand All @@ -147,14 +240,7 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
return (
<Field label={label} data-testid={`AdHocFilter-${filter.key}`} className={styles.field}>
<div className={styles.wrapper}>
<Select
className={styles.operator}
value={filter.operator}
disabled={model.state.readOnly}
options={model._getOperators()}
width="auto"
onChange={(v) => model._updateFilter(filter, 'operator', v)}
/>
{operatorSelect}
{valueSelect}
</div>
</Field>
Expand All @@ -171,14 +257,7 @@ export function AdHocFilterRenderer({ filter, model }: Props) {
return (
<div className={styles.wrapper} data-testid={`AdHocFilter-${filter.key}`}>
{keySelect}
<Select
className={styles.operator}
value={filter.operator}
disabled={model.state.readOnly}
options={model._getOperators()}
width="auto"
onChange={(v) => model._updateFilter(filter, 'operator', v)}
/>
{operatorSelect}
{valueSelect}
<Button
variant="secondary"
Expand Down Expand Up @@ -238,14 +317,18 @@ const getStyles = (theme: GrafanaTheme2) => ({
minWidth: theme.spacing(16),
}),
value: css({
flexBasis: 'content',
flexShrink: 1,
minWidth: '90px',
}),
key: css({
flexBasis: 'content',
minWidth: '90px',
flexShrink: 1,
}),
operator: css({
flexShrink: 0,
flexBasis: 'content',
}),
removeButton: css({
paddingLeft: theme.spacing(3 / 2),
Expand Down
Loading

0 comments on commit 1eacb00

Please sign in to comment.