[Controls] Range slider a11y and performance improvements (#159271)

Closes https://github.com/elastic/kibana/issues/135466

## Summary

The main goal of this PR is to fix the serious "Buttons must have
discernible text" a11y failure - this is accomplished by switching from
building our own range slider button using `EuiFlexGroup` to instead
using `EuiFormControlLayoutDelimited`, which both resolves these a11y
issues and also fixes a rendering regression:

| Before | After |
|--------|-------|
|
![image](49ea1516-db74-46af-baa5-4ad0a31d5b5a)
|
![image](71bc61f2-f10d-4f8c-8ad2-2681f7faf921)
|

As part of this, I also took some time to clean up some of the range
slider code, which hasn't really been touched in awhile - this
includes...
- moving the debounce on range selections from the embeddable's `input$`
subscription to the component itself, as described
[here](https://github.com/elastic/kibana/pull/159271#discussion_r1226886857).
- fixing a bug where resetting the range slider would unnecessarily
cause unsaved changes, as described
[here](https://github.com/elastic/kibana/pull/159271#discussion_r1226885018).
- improving the `onClick` behaviour (with some notable limitations), as
described
[here](https://github.com/elastic/kibana/pull/159271#discussion_r1226934124).

As a follow up, we need to move the "clear selections" button [to a
hover action](https://github.com/elastic/kibana/issues/159395), which
will enable us to then fully move forward with our transition to the
`EuiDualRange` component.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] ~Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard
accessibility](https://webaim.org/techniques/keyboard/))~
   > **Note**
> Details provided
[here](https://github.com/elastic/kibana/pull/159271#discussion_r1226934124)
on why only partial keyboard support is currently supported
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Hannah Mudge 2023-06-15 10:58:11 -06:00 committed by GitHub
parent c915c0508b
commit 9b0f10629b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 307 additions and 310 deletions

View file

@ -10,6 +10,7 @@ import deepEqual from 'fast-deep-equal';
import { omit, isEqual } from 'lodash';
import { OPTIONS_LIST_DEFAULT_SORT } from '../options_list/suggestions_sorting';
import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from '../options_list/types';
import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from '../range_slider/types';
import { ControlPanelState } from './types';
@ -26,6 +27,19 @@ export const genericControlPanelDiffSystem: DiffSystem = {
export const ControlPanelDiffSystems: {
[key: string]: DiffSystem;
} = {
[RANGE_SLIDER_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {
return false;
}
const { value: valueA = ['', ''] }: Partial<RangeSliderEmbeddableInput> =
initialInput.explicitInput;
const { value: valueB = ['', ''] }: Partial<RangeSliderEmbeddableInput> =
newInput.explicitInput;
return isEqual(valueA, valueB);
},
},
[OPTIONS_LIST_CONTROL]: {
getPanelIsEqual: (initialInput, newInput) => {
if (!deepEqual(omit(initialInput, 'explicitInput'), omit(newInput, 'explicitInput'))) {

View file

@ -13,7 +13,7 @@ export const RANGE_SLIDER_CONTROL = 'rangeSliderControl';
export type RangeValue = [string, string];
export interface RangeSliderEmbeddableInput extends DataControlInput {
value: RangeValue;
value?: RangeValue;
}
export type RangeSliderInputWithType = Partial<RangeSliderEmbeddableInput> & { type: string };

View file

@ -33,7 +33,7 @@
color: $euiTextSubduedColor;
text-decoration: line-through;
margin-left: $euiSizeS;
font-weight: 300;
font-weight: $euiFontWeightRegular;
}
.optionsList__existsFilter {

View file

@ -17,42 +17,36 @@
}
.rangeSliderAnchor__button {
display: flex;
align-items: center;
width: 100%;
height: 100%;
justify-content: space-between;
background-color: $euiFormBackgroundColor;
@include euiFormControlSideBorderRadius($euiFormControlBorderRadius, $side: 'right', $internal: true);
padding: 0;
.euiFormControlLayout__childrenWrapper {
border-radius: 0 $euiFormControlBorderRadius $euiFormControlBorderRadius 0 !important;
}
.euiToolTipAnchor {
width: 100%;
}
.rangeSliderAnchor__delimiter {
background-color: unset;
padding: $euiSizeS*1.5 0;
}
.rangeSliderAnchor__fieldNumber {
font-weight: $euiFontWeightBold;
box-shadow: none;
text-align: center;
background-color: unset;
&:invalid {
color: $euiTextSubduedColor;
text-decoration: line-through;
font-weight: $euiFontWeightRegular;
background-image: none; // hide the red bottom border
}
&::placeholder {
font-weight: $euiFontWeightRegular;
color: $euiColorMediumShade;
text-decoration: none;
}
}
.rangeSliderAnchor__fieldNumber--invalid {
text-decoration: line-through;
font-weight: $euiFontWeightRegular;
color: $euiColorMediumShade;
}
.rangeSliderAnchor__spinner {
padding-right: $euiSizeS;
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { EuiFieldNumber, EuiFormControlLayoutDelimited } from '@elastic/eui';
import './range_slider.scss';
import { RangeValue } from '../../../common/range_slider/types';
import { useRangeSlider } from '../embeddable/range_slider_embeddable';
export const RangeSliderButton = ({
value,
onChange,
isPopoverOpen,
setIsPopoverOpen,
}: {
value: RangeValue;
isPopoverOpen: boolean;
setIsPopoverOpen: (open: boolean) => void;
onChange: (newRange: RangeValue) => void;
}) => {
const rangeSlider = useRangeSlider();
const min = rangeSlider.select((state) => state.componentState.min);
const max = rangeSlider.select((state) => state.componentState.max);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
const id = rangeSlider.select((state) => state.explicitInput.id);
const isLoading = rangeSlider.select((state) => state.output.loading);
const onClick = useCallback(
(event) => {
// the popover should remain open if the click/focus target is one of the number inputs
if (isPopoverOpen && event.target instanceof HTMLInputElement) {
return;
}
setIsPopoverOpen(true);
},
[isPopoverOpen, setIsPopoverOpen]
);
return (
<EuiFormControlLayoutDelimited
fullWidth
onClick={onClick}
isLoading={isLoading}
className="rangeSliderAnchor__button"
data-test-subj={`range-slider-control-${id}`}
startControl={
<EuiFieldNumber
controlOnly
fullWidth
value={value[0] === String(min) ? '' : value[0]}
onChange={(event) => {
onChange([event.target.value, value[1]]);
}}
placeholder={String(min)}
isInvalid={isInvalid}
className={'rangeSliderAnchor__fieldNumber'}
data-test-subj={'rangeSlider__lowerBoundFieldNumber'}
/>
}
endControl={
<EuiFieldNumber
controlOnly
fullWidth
value={value[1] === String(max) ? '' : value[1]}
onChange={(event) => {
onChange([value[0], event.target.value]);
}}
placeholder={String(max)}
isInvalid={isInvalid}
className={'rangeSliderAnchor__fieldNumber'}
data-test-subj={'rangeSlider__upperBoundFieldNumber'}
/>
}
/>
);
};

View file

@ -6,24 +6,18 @@
* Side Public License, v 1.
*/
import React, { FC, useState, useRef } from 'react';
import { debounce } from 'lodash';
import React, { FC, useState, useRef, useMemo, useEffect } from 'react';
import {
EuiFieldNumber,
EuiText,
EuiInputPopover,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { EuiInputPopover } from '@elastic/eui';
import { useRangeSlider } from '../embeddable/range_slider_embeddable';
import { RangeSliderPopover, EuiDualRangeRef } from './range_slider_popover';
import './range_slider.scss';
import { ControlError } from '../../control_group/component/control_error_component';
const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid';
import { RangeValue } from '../../../common/range_slider/types';
import { RangeSliderButton } from './range_slider_button';
import './range_slider.scss';
export const RangeSliderControl: FC = () => {
const rangeRef = useRef<EuiDualRangeRef>(null);
@ -31,92 +25,33 @@ export const RangeSliderControl: FC = () => {
const rangeSlider = useRangeSlider();
const min = rangeSlider.select((state) => state.componentState.min);
const max = rangeSlider.select((state) => state.componentState.max);
const error = rangeSlider.select((state) => state.componentState.error);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
const value = rangeSlider.select((state) => state.explicitInput.value);
const [displayedValue, setDisplayedValue] = useState<RangeValue>(value ?? ['', '']);
const id = rangeSlider.select((state) => state.explicitInput.id);
const value = rangeSlider.select((state) => state.explicitInput.value) ?? ['', ''];
const isLoading = rangeSlider.select((state) => state.output.loading);
const debouncedOnChange = useMemo(
() =>
debounce((newRange: RangeValue) => {
rangeSlider.dispatch.setSelectedRange(newRange);
}, 750),
[rangeSlider.dispatch]
);
const hasAvailableRange = min !== '' && max !== '';
useEffect(() => {
debouncedOnChange(displayedValue);
}, [debouncedOnChange, displayedValue]);
const hasLowerBoundSelection = value[0] !== '';
const hasUpperBoundSelection = value[1] !== '';
const lowerBoundValue = parseFloat(value[0]);
const upperBoundValue = parseFloat(value[1]);
const minValue = parseFloat(min);
const maxValue = parseFloat(max);
// EuiDualRange can only handle integers as min/max
const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue;
const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue;
useEffect(() => {
setDisplayedValue(value ?? ['', '']);
}, [value]);
const button = (
<button
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
className="rangeSliderAnchor__button"
data-test-subj={`range-slider-control-${id}`}
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiFieldNumber
controlOnly
fullWidth
className={`rangeSliderAnchor__fieldNumber ${
hasLowerBoundSelection && isInvalid ? INVALID_CLASS : ''
}`}
value={hasLowerBoundSelection ? lowerBoundValue : ''}
onChange={(event) => {
rangeSlider.dispatch.setSelectedRange([
event.target.value,
isNaN(upperBoundValue) ? '' : String(upperBoundValue),
]);
}}
disabled={isLoading}
placeholder={`${hasAvailableRange ? roundedMin : ''}`}
isInvalid={isInvalid}
data-test-subj="rangeSlider__lowerBoundFieldNumber"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText className="rangeSliderAnchor__delimiter" size="s" color="subdued">
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldNumber
controlOnly
fullWidth
className={`rangeSliderAnchor__fieldNumber ${
hasUpperBoundSelection && isInvalid ? INVALID_CLASS : ''
}`}
value={hasUpperBoundSelection ? upperBoundValue : ''}
onChange={(event) => {
rangeSlider.dispatch.setSelectedRange([
isNaN(lowerBoundValue) ? '' : String(lowerBoundValue),
event.target.value,
]);
}}
disabled={isLoading}
placeholder={`${hasAvailableRange ? roundedMax : ''}`}
isInvalid={isInvalid}
data-test-subj="rangeSlider__upperBoundFieldNumber"
/>
</EuiFlexItem>
{isLoading ? (
<EuiFlexItem
grow={false}
className="rangeSliderAnchor__spinner"
data-test-subj="range-slider-loading-spinner"
>
<EuiLoadingSpinner />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</button>
<RangeSliderButton
value={displayedValue}
onChange={setDisplayedValue}
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
/>
);
return error ? (
@ -130,7 +65,9 @@ export const RangeSliderControl: FC = () => {
className="rangeSlider__popoverOverride"
anchorClassName="rangeSlider__anchorOverride"
panelClassName="rangeSlider__panelOverride"
closePopover={() => setIsPopoverOpen(false)}
closePopover={() => {
setIsPopoverOpen(false);
}}
anchorPosition="downCenter"
attachToAnchor={false}
disableFocusTrap
@ -138,7 +75,7 @@ export const RangeSliderControl: FC = () => {
rangeRef.current?.onResize(width);
}}
>
<RangeSliderPopover rangeRef={rangeRef} />
<RangeSliderPopover rangeRef={rangeRef} value={displayedValue} onChange={setDisplayedValue} />
</EuiInputPopover>
);
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { FC, ComponentProps, Ref, useEffect, useState } from 'react';
import React, { FC, ComponentProps, Ref, useEffect, useState, useMemo } from 'react';
import useMount from 'react-use/lib/useMount';
import {
@ -14,9 +14,9 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiDualRange,
EuiText,
EuiToolTip,
EuiButtonIcon,
EuiText,
} from '@elastic/eui';
import type { EuiDualRangeClass } from '@elastic/eui/src/components/form/range/dual_range';
@ -28,7 +28,11 @@ import { useRangeSlider } from '../embeddable/range_slider_embeddable';
// Unfortunately, wrapping EuiDualRange in `withEuiTheme` has created this annoying/verbose typing
export type EuiDualRangeRef = EuiDualRangeClass & ComponentProps<typeof EuiDualRange>;
export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ rangeRef }) => {
export const RangeSliderPopover: FC<{
value: RangeValue;
onChange: (newRange: RangeValue) => void;
rangeRef?: Ref<EuiDualRangeRef>;
}> = ({ onChange, value, rangeRef }) => {
const [fieldFormatter, setFieldFormatter] = useState(() => (toFormat: string) => toFormat);
// Controls Services Context
@ -39,79 +43,38 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
// Select current state from Redux using multiple selectors to avoid rerenders.
const dataViewId = rangeSlider.select((state) => state.output.dataViewId);
const fieldSpec = rangeSlider.select((state) => state.componentState.field);
const id = rangeSlider.select((state) => state.explicitInput.id);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
const max = rangeSlider.select((state) => state.componentState.max);
const min = rangeSlider.select((state) => state.componentState.min);
const title = rangeSlider.select((state) => state.explicitInput.title);
const value = rangeSlider.select((state) => state.explicitInput.value) ?? ['', ''];
const hasAvailableRange = min !== '' && max !== '';
const hasLowerBoundSelection = value[0] !== '';
const hasUpperBoundSelection = value[1] !== '';
const lowerBoundSelection = parseFloat(value[0]);
const upperBoundSelection = parseFloat(value[1]);
const minValue = parseFloat(min);
const maxValue = parseFloat(max);
// EuiDualRange can only handle integers as min/max
const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue;
const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue;
const min = rangeSlider.select((state) => state.componentState.min);
const max = rangeSlider.select((state) => state.componentState.max);
const fieldSpec = rangeSlider.select((state) => state.componentState.field);
const isInvalid = rangeSlider.select((state) => state.componentState.isInvalid);
// Caches min and max displayed on popover open so the range slider doesn't resize as selections change
const [rangeSliderMin, setRangeSliderMin] = useState<number>(roundedMin);
const [rangeSliderMax, setRangeSliderMax] = useState<number>(roundedMax);
const [rangeSliderMin, setRangeSliderMin] = useState<number>(min);
const [rangeSliderMax, setRangeSliderMax] = useState<number>(max);
useMount(() => {
const [lowerBoundSelection, upperBoundSelection] = [parseFloat(value[0]), parseFloat(value[1])];
setRangeSliderMin(
Math.min(
roundedMin,
min,
isNaN(lowerBoundSelection) ? Infinity : lowerBoundSelection,
isNaN(upperBoundSelection) ? Infinity : upperBoundSelection
)
);
setRangeSliderMax(
Math.max(
roundedMax,
max,
isNaN(lowerBoundSelection) ? -Infinity : lowerBoundSelection,
isNaN(upperBoundSelection) ? -Infinity : upperBoundSelection
)
);
});
const errorMessage = '';
let helpText = '';
if (!hasAvailableRange) {
helpText = RangeSliderStrings.popover.getNoAvailableDataHelpText();
} else if (isInvalid) {
helpText = RangeSliderStrings.popover.getNoDataHelpText();
}
const displayedValue = [
hasLowerBoundSelection
? String(lowerBoundSelection)
: hasAvailableRange
? String(roundedMin)
: '',
hasUpperBoundSelection
? String(upperBoundSelection)
: hasAvailableRange
? String(roundedMax)
: '',
] as RangeValue;
const ticks = [];
const levels = [];
if (hasAvailableRange) {
ticks.push({ value: rangeSliderMin, label: fieldFormatter(String(rangeSliderMin)) });
ticks.push({ value: rangeSliderMax, label: fieldFormatter(String(rangeSliderMax)) });
levels.push({ min: roundedMin, max: roundedMax, color: 'success' });
}
// derive field formatter from fieldSpec and dataViewId
useEffect(() => {
(async () => {
@ -126,6 +89,17 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
})();
}, [fieldSpec, dataViewId, getDataViewById]);
const ticks = useMemo(() => {
return [
{ value: min, label: fieldFormatter(String(min)) },
{ value: max, label: fieldFormatter(String(max)) },
];
}, [min, max, fieldFormatter]);
const levels = useMemo(() => {
return [{ min, max, color: 'success' }];
}, [min, max]);
return (
<>
<EuiPopoverTitle paddingSize="s">{title}</EuiPopoverTitle>
@ -136,34 +110,31 @@ export const RangeSliderPopover: FC<{ rangeRef?: Ref<EuiDualRangeRef> }> = ({ ra
responsive={false}
>
<EuiFlexItem>
<EuiDualRange
id={id}
min={hasAvailableRange ? rangeSliderMin : 0}
max={hasAvailableRange ? rangeSliderMax : 100}
onChange={([newLowerBound, newUpperBound]) => {
const updatedLowerBound =
typeof newLowerBound === 'number' ? String(newLowerBound) : value[0];
const updatedUpperBound =
typeof newUpperBound === 'number' ? String(newUpperBound) : value[1];
rangeSlider.dispatch.setSelectedRange([updatedLowerBound, updatedUpperBound]);
}}
value={displayedValue}
ticks={hasAvailableRange ? ticks : undefined}
levels={hasAvailableRange ? levels : undefined}
showTicks={hasAvailableRange}
disabled={!hasAvailableRange}
fullWidth
ref={rangeRef}
data-test-subj="rangeSlider__slider"
/>
<EuiText
size="s"
color={errorMessage ? 'danger' : 'default'}
data-test-subj="rangeSlider__helpText"
>
{errorMessage || helpText}
</EuiText>
{min !== -Infinity && max !== Infinity ? (
<EuiDualRange
id={id}
min={rangeSliderMin}
max={rangeSliderMax}
onChange={([minSelection, maxSelection]) => {
onChange([String(minSelection), String(maxSelection)]);
}}
value={value}
ticks={ticks}
levels={levels}
showTicks
fullWidth
ref={rangeRef}
data-test-subj="rangeSlider__slider"
/>
) : isInvalid ? (
<EuiText size="s" data-test-subj="rangeSlider__helpText">
{RangeSliderStrings.popover.getNoDataHelpText()}
</EuiText>
) : (
<EuiText size="s" data-test-subj="rangeSlider__helpText">
{RangeSliderStrings.popover.getNoAvailableDataHelpText()}
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={RangeSliderStrings.popover.getClearRangeButtonTitle()}>

View file

@ -13,7 +13,7 @@ import { batch } from 'react-redux';
import { get, isEqual } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { Subscription, lastValueFrom } from 'rxjs';
import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators';
import { distinctUntilChanged, skip, map } from 'rxjs/operators';
import {
compareFilters,
@ -21,7 +21,6 @@ import {
COMPARE_ALL_OPTIONS,
RangeFilterParams,
Filter,
Query,
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
@ -63,7 +62,12 @@ interface RangeSliderDataFetchProps {
}
const fieldMissingError = (fieldName: string) =>
new Error(`field ${fieldName} not found in index pattern`);
new Error(
i18n.translate('controls.rangeSlider.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldName}',
values: { fieldName },
})
);
export const RangeSliderControlContext = createContext<RangeSliderEmbeddable | null>(null);
export const useRangeSlider = (): RangeSliderEmbeddable => {
@ -93,6 +97,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
// Internal data fetching state for this input control.
private dataView?: DataView;
private field?: DataViewField;
private filters: Filter[] = [];
// state management
public select: RangeSliderReduxEmbeddableTools['select'];
@ -126,7 +131,6 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.dispatch = reduxEmbeddableTools.dispatch;
this.onStateChange = reduxEmbeddableTools.onStateChange;
this.cleanupStateTools = reduxEmbeddableTools.cleanup;
this.initialize();
}
@ -136,19 +140,19 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.setInitializationFinished();
}
this.runRangeSliderQuery()
.catch((e) => {
batch(() => {
this.dispatch.setLoading(false);
this.dispatch.setErrorMessage(e.message);
});
})
.then(async () => {
if (initialValue) {
this.setInitializationFinished();
}
this.setupSubscriptions();
try {
await this.runRangeSliderQuery();
await this.buildFilter();
if (initialValue) {
this.setInitializationFinished();
}
} catch (e) {
batch(() => {
this.dispatch.setLoading(false);
this.dispatch.setErrorMessage(e.message);
});
}
this.setupSubscriptions();
};
private setupSubscriptions = () => {
@ -169,19 +173,21 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
// fetch available min/max when input changes
this.subscriptions.add(
dataFetchPipe.subscribe(async () =>
this.runRangeSliderQuery().catch((e) => {
dataFetchPipe.subscribe(async (changes) => {
try {
await this.runRangeSliderQuery();
await this.buildFilter();
} catch (e) {
this.dispatch.setErrorMessage(e.message);
})
)
}
})
);
// build filters when value change
// build filters when value changes
this.subscriptions.add(
this.getInput$()
.pipe(
debounceTime(400),
distinctUntilChanged((a, b) => isEqual(a.value, b.value)),
distinctUntilChanged((a, b) => isEqual(a.value ?? ['', ''], b.value ?? ['', ''])),
skip(1) // skip the first input update because initial filters will be built by initialize.
)
.subscribe(this.buildFilter)
@ -217,12 +223,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
try {
this.field = this.dataView.getFieldByName(fieldName);
if (this.field === undefined) {
throw new Error(
i18n.translate('controls.rangeSlider.errors.fieldNotFound', {
defaultMessage: 'Could not locate field: {fieldName}',
values: { fieldName },
})
);
throw fieldMissingError(fieldName);
}
this.dispatch.setField(this.field?.toSpec());
@ -240,15 +241,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
const embeddableInput = this.getInput();
const {
ignoreParentSettings,
fieldName,
query,
timeRange: globalTimeRange,
timeslice,
} = embeddableInput;
let { filters = [] } = embeddableInput;
const { fieldName } = this.getInput();
if (!field) {
batch(() => {
@ -258,9 +251,9 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
throw fieldMissingError(fieldName);
}
if (ignoreParentSettings?.ignoreFilters) {
filters = [];
}
const embeddableInput = this.getInput();
const { ignoreParentSettings, timeRange: globalTimeRange, timeslice } = embeddableInput;
let { filters = [] } = embeddableInput;
const timeRange =
timeslice !== undefined
@ -277,41 +270,36 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
}
}
this.filters = filters;
const { min, max } = await this.fetchMinMax({
dataView,
field,
filters,
query,
}).catch((e) => {
throw e;
});
this.dispatch.setMinMax({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
});
// build filter with new min/max
await this.buildFilter().catch((e) => {
throw e;
min: `${min ?? '-Infinity'}`,
max: `${max ?? 'Infinity'}`,
});
};
private fetchMinMax = async ({
dataView,
field,
filters,
query,
}: {
dataView: DataView;
field: DataViewField;
filters: Filter[];
query?: Query;
}) => {
}): Promise<{ min?: number; max?: number }> => {
const searchSource = await this.dataService.searchSource.create();
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
searchSource.setField('filter', filters);
const { ignoreParentSettings, query } = this.getInput();
if (!ignoreParentSettings?.ignoreFilters) {
searchSource.setField('filter', this.filters);
}
if (query) {
searchSource.setField('query', query);
@ -343,8 +331,8 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
const resp = await lastValueFrom(searchSource.fetch$()).catch((e) => {
throw e;
});
const min = get(resp, 'rawResponse.aggregations.minAgg.value', '');
const max = get(resp, 'rawResponse.aggregations.maxAgg.value', '');
const min = get(resp, 'rawResponse.aggregations.minAgg.value');
const max = get(resp, 'rawResponse.aggregations.maxAgg.value');
return { min, max };
};
@ -352,15 +340,13 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
private buildFilter = async () => {
const {
componentState: { min: availableMin, max: availableMax },
explicitInput: {
query,
timeRange,
filters = [],
ignoreParentSettings,
value: [selectedMin, selectedMax] = ['', ''],
},
explicitInput: { value },
} = this.getState();
const hasData = !isEmpty(availableMin) && !isEmpty(availableMax);
const { ignoreParentSettings, query } = this.getInput();
const [selectedMin, selectedMax] = value ?? ['', ''];
const hasData = availableMin !== undefined && availableMax !== undefined;
const hasLowerSelection = !isEmpty(selectedMin);
const hasUpperSelection = !isEmpty(selectedMax);
const hasEitherSelection = hasLowerSelection || hasUpperSelection;
@ -382,15 +368,14 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
const params = {} as RangeFilterParams;
if (selectedMin) {
params.gte = Math.max(parseFloat(selectedMin), parseFloat(availableMin));
params.gte = Math.max(parseFloat(selectedMin), availableMin);
}
if (selectedMax) {
params.lte = Math.min(parseFloat(selectedMax), parseFloat(availableMax));
params.lte = Math.min(parseFloat(selectedMax), availableMax);
}
const rangeFilter = buildRangeFilter(field, params, dataView);
rangeFilter.meta.key = field?.name;
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
@ -399,19 +384,11 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!ignoreParentSettings?.ignoreValidations) {
const searchSource = await this.dataService.searchSource.create();
filters.push(rangeFilter);
const timeFilter = this.dataService.timefilter.createFilter(dataView, timeRange);
if (timeFilter) {
filters.push(timeFilter);
}
const filters = [...this.filters, rangeFilter];
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
searchSource.setField('filter', filters);
if (query) {
searchSource.setField('query', query);
}
@ -423,7 +400,6 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
} = await lastValueFrom(searchSource.fetch$());
const docCount = typeof total === 'number' ? total : total?.value;
if (!docCount) {
batch(() => {
this.dispatch.setLoading(false);
@ -445,10 +421,13 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
});
};
public reload = () => {
this.runRangeSliderQuery().catch((e) => {
public reload = async () => {
try {
await this.runRangeSliderQuery();
await this.buildFilter();
} catch (e) {
this.dispatch.setErrorMessage(e.message);
});
}
};
public destroy = () => {

View file

@ -16,9 +16,9 @@ import { RangeSliderReduxState } from './types';
import { RangeValue } from '../../common/range_slider/types';
export const getDefaultComponentState = (): RangeSliderReduxState['componentState'] => ({
min: '',
max: '',
isInvalid: false,
min: -Infinity,
max: Infinity,
});
export const rangeSliderReducers = {
@ -53,8 +53,8 @@ export const rangeSliderReducers = {
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<{ min: string; max: string }>
) => {
state.componentState.min = action.payload.min;
state.componentState.max = action.payload.max;
state.componentState.min = Math.floor(parseFloat(action.payload.min));
state.componentState.max = Math.ceil(parseFloat(action.payload.max));
},
publishFilters: (
state: WritableDraft<RangeSliderReduxState>,

View file

@ -15,8 +15,8 @@ import { ControlOutput } from '../types';
// Component state is only used by public components.
export interface RangeSliderComponentState {
field?: FieldSpec;
min: string;
max: string;
min: number;
max: number;
error?: string;
isInvalid?: boolean;
}

View file

@ -132,7 +132,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await saveButton.isEnabled()).to.be(false);
await dashboardControls.controlsEditorSetfield('dayOfWeek', RANGE_SLIDER_CONTROL);
await dashboardControls.controlEditorSave();
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.rangeSliderWaitForLoading(firstId);
await dashboardControls.validateRange('placeholder', firstId, '0', '6');
await dashboardControls.validateRange('value', firstId, '', '');
@ -164,11 +164,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'value'
);
expect(upperBoundSelection).to.be('2');
await dashboardControls.rangeSliderWaitForLoading(firstId);
});
it('applies filter from the first control on the second control', async () => {
await dashboardControls.rangeSliderWaitForLoading();
const secondId = (await dashboardControls.getAllControlIds())[1];
await dashboardControls.rangeSliderWaitForLoading(secondId);
await dashboardControls.validateRange('placeholder', secondId, '100', '1000');
await dashboard.clearUnsavedChanges();
});
@ -183,15 +184,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('making changes to range causes unsaved changes', async () => {
const firstId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderSetLowerBound(firstId, '0');
await dashboardControls.rangeSliderSetLowerBound(firstId, '2');
await dashboardControls.rangeSliderSetUpperBound(firstId, '3');
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.rangeSliderWaitForLoading(firstId);
await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
});
it('changes to range can be discarded', async () => {
const firstId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.validateRange('value', firstId, '0', '3');
await dashboardControls.validateRange('value', firstId, '2', '3');
await dashboard.clickCancelOutOfEditMode();
await dashboardControls.validateRange('value', firstId, '', '');
});
@ -216,7 +217,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.rangeSliderSetUpperBound(firstId, '400');
});
it('disables range slider when no data available', async () => {
it('hides range slider in popover when no data available', async () => {
await dashboardControls.createControl({
controlType: RANGE_SLIDER_CONTROL,
dataViewTitle: 'logstash-*',
@ -226,9 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const secondId = (await dashboardControls.getAllControlIds())[1];
await dashboardControls.rangeSliderOpenPopover(secondId);
await dashboardControls.rangeSliderPopoverAssertOpen();
expect(
await dashboardControls.rangeSliderGetDualRangeAttribute(secondId, 'disabled')
).to.be('true');
await testSubjects.missingOrFail('rangeSlider__slider');
expect((await testSubjects.getVisibleText('rangeSlider__helpText')).length).to.be.above(0);
});
});
@ -250,7 +249,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Applies dashboard query to range slider control', async () => {
const firstId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.rangeSliderWaitForLoading(firstId);
await dashboardControls.validateRange('placeholder', firstId, '100', '300');
await queryBar.setQuery('');
await queryBar.submitQuery();

View file

@ -41,7 +41,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const replaceWithRangeSlider = async (controlId: string, field: string) => {
await changeFieldType(controlId, field, RANGE_SLIDER_CONTROL);
await retry.try(async () => {
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.rangeSliderWaitForLoading(controlId);
await dashboardControls.verifyControlType(controlId, 'range-slider-control');
});
};
@ -102,8 +102,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
dataViewTitle: 'animals-*',
fieldName: 'weightLbs',
});
await dashboardControls.rangeSliderWaitForLoading();
controlId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderWaitForLoading(controlId);
});
afterEach(async () => {

View file

@ -96,8 +96,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('applies filter from the first control on the second control', async () => {
await dashboardControls.rangeSliderWaitForLoading();
const secondId = (await dashboardControls.getAllControlIds())[1];
await dashboardControls.rangeSliderWaitForLoading(secondId);
await dashboardControls.validateRange('placeholder', secondId, '101', '1000');
});

View file

@ -336,11 +336,28 @@ export class DashboardPageControls extends FtrService {
}
public async verifyControlType(controlId: string, expectedType: string) {
const controlButton = await this.find.byXPath(
`//div[@id='controlFrame--${controlId}']//button`
);
const testSubj = await controlButton.getAttribute('data-test-subj');
expect(testSubj).to.equal(`${expectedType}-${controlId}`);
let controlButton;
switch (expectedType) {
case OPTIONS_LIST_CONTROL: {
controlButton = await this.find.byXPath(`//div[@id='controlFrame--${controlId}']//button`);
break;
}
case RANGE_SLIDER_CONTROL: {
controlButton = await this.find.byXPath(
`//div[@id='controlFrame--${controlId}']//div[contains(@class, 'rangeSliderAnchor__button')]`
);
break;
}
default: {
this.log.error('An invalid control type was provided.');
break;
}
}
if (controlButton) {
const testSubj = await controlButton.getAttribute('data-test-subj');
expect(testSubj).to.equal(`${expectedType}-${controlId}`);
}
}
// Options list functions
@ -636,10 +653,7 @@ export class DashboardPageControls extends FtrService {
attribute
);
}
public async rangeSliderGetDualRangeAttribute(controlId: string, attribute: string) {
this.log.debug(`Getting range slider dual range ${attribute} for ${controlId}`);
return await this.testSubjects.getAttribute(`rangeSlider__slider`, attribute);
}
public async rangeSliderSetLowerBound(controlId: string, value: string) {
this.log.debug(`Setting range slider lower bound to ${value}`);
await this.testSubjects.setValue(
@ -665,7 +679,8 @@ export class DashboardPageControls extends FtrService {
public async rangeSliderEnsurePopoverIsClosed(controlId: string) {
this.log.debug(`Opening popover for Range Slider: ${controlId}`);
await this.testSubjects.click(`range-slider-control-${controlId}`);
const controlLabel = await this.find.byXPath(`//div[@data-control-id='${controlId}']//label`);
await controlLabel.click();
await this.testSubjects.waitForDeleted(`rangeSlider-control-actions`);
}
@ -677,8 +692,10 @@ export class DashboardPageControls extends FtrService {
});
}
public async rangeSliderWaitForLoading() {
await this.testSubjects.waitForDeleted('range-slider-loading-spinner');
public async rangeSliderWaitForLoading(controlId: string) {
await this.find.waitForDeletedByCssSelector(
`[data-test-subj="range-slider-control-${controlId}"] .euiLoadingSpinner`
);
}
public async rangeSliderClearSelection(controlId: string) {

View file

@ -309,7 +309,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboardControls.optionsListOpenPopover(optionsListControl);
await PageObjects.dashboardControls.optionsListPopoverSelectOption('CN');
await PageObjects.dashboardControls.optionsListPopoverSelectOption('US');
await PageObjects.dashboardControls.rangeSliderWaitForLoading(); // wait for range slider to respond to options list selections before proceeding
await PageObjects.dashboardControls.rangeSliderWaitForLoading(rangeSliderControl); // wait for range slider to respond to options list selections before proceeding
await PageObjects.dashboardControls.rangeSliderSetLowerBound(rangeSliderControl, '1000');
await PageObjects.dashboardControls.rangeSliderSetUpperBound(rangeSliderControl, '15000');
await PageObjects.dashboard.clickQuickSave();