[Controls] Add ability to recover from non-fatal error state (#158087)

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

## Summary

The only reason a control embeddable should enter a fatal error state is
if, for some reason, the embeddable actually cannot be created (for
example, trying to create a control with a type that doesn't exist) -
every other error should be considered **recoverable** as much as
possible. So, this PR ensures that both the options list and the range
slider are able to recover from most errors by switching from calling
`onFatalError` to instead handling most errors internally via component
state.

> **Note**
> The time slider control does not have any errors that would be
considered "recoverable" because it is actually **much more difficult**
to enter an error state for this control; so, I did not need to change
anything for this control type.

### Errors:

- **Recoverable error:**
   - **Before:**
   

7737a3e8-1c97-47ba-92ab-55f5e1a6c30a
   
   - **After:**
   

e4ced721-2b84-497e-8608-965877409bf5

- **Unrecoverable error:**

To test this, I've created a dashboard saved object with a control type
that does not exist:
[controlTypeDoesNotExistDashboard.ndjson.zip](11547128/controlTypeDoesNotExistDashboard.ndjson.zip).
Try importing this dashboard and ensure that you can actually see an
error unlike the "before" state:

   - **Before:**


![image](27c581b1-3fa8-4c07-b102-c952355dfd32)

   - **After:**
   

![image](626ed696-4f8e-44b2-b449-3c2fa1ee1327)


### Flaky Test Runner

- [Options list dashboard interavction
(`test/functional/apps/dashboard_elements/controls/options_list/options_list_dashboard_interaction.ts`)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2316)

![image](6b71c71c-a47b-4e84-834a-07b8c380a727)

- [Range slider
(`test/functional/apps/dashboard_elements/controls/range_slider.ts`)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2317)

![image](fe1c9752-68d3-4bca-b31a-361217b91d8e)


### 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
- [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-05-31 13:39:20 -06:00 committed by GitHub
parent ccf0099470
commit 3e3419d6c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 194 additions and 128 deletions

View file

@ -0,0 +1,57 @@
/*
* 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, { useState } from 'react';
import { EuiButtonEmpty, EuiPopover } from '@elastic/eui';
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { Markdown } from '@kbn/kibana-react-plugin/public';
interface ControlErrorProps {
error: Error | string;
}
export const ControlError = ({ error }: ControlErrorProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const errorMessage = error instanceof Error ? error.message : error;
const popoverButton = (
<EuiButtonEmpty
color="danger"
iconSize="m"
iconType="error"
data-test-subj="control-frame-error"
onClick={() => setPopoverOpen((open) => !open)}
className={'errorEmbeddableCompact__button'}
textProps={{ className: 'errorEmbeddableCompact__text' }}
>
<FormattedMessage
id="controls.frame.error.message"
defaultMessage="An error occurred. View more"
/>
</EuiButtonEmpty>
);
return (
<I18nProvider>
<EuiPopover
button={popoverButton}
isOpen={isPopoverOpen}
className="errorEmbeddableCompact__popover"
anchorClassName="errorEmbeddableCompact__popoverAnchor"
closePopover={() => setPopoverOpen(false)}
>
<Markdown
markdown={errorMessage}
openLinksInNewTab={true}
data-test-subj="errorMessageMarkdown"
/>
</EuiPopover>
</I18nProvider>
);
};

View file

@ -10,16 +10,13 @@ import classNames from 'classnames';
import React, { useEffect, useMemo, useState } from 'react';
import {
EuiButtonEmpty,
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
EuiLoadingChart,
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
import {
@ -28,45 +25,7 @@ import {
} from '../embeddable/control_group_container';
import { ControlGroupStrings } from '../control_group_strings';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
interface ControlFrameErrorProps {
error: Error;
}
const ControlFrameError = ({ error }: ControlFrameErrorProps) => {
const [isPopoverOpen, setPopoverOpen] = useState(false);
const popoverButton = (
<EuiButtonEmpty
color="danger"
iconSize="m"
iconType="error"
onClick={() => setPopoverOpen((open) => !open)}
className={'errorEmbeddableCompact__button'}
textProps={{ className: 'errorEmbeddableCompact__text' }}
>
<FormattedMessage
id="controls.frame.error.message"
defaultMessage="An error occurred. View more"
/>
</EuiButtonEmpty>
);
return (
<EuiPopover
button={popoverButton}
isOpen={isPopoverOpen}
className="errorEmbeddableCompact__popover"
anchorClassName="errorEmbeddableCompact__popoverAnchor"
closePopover={() => setPopoverOpen(false)}
>
<Markdown
markdown={error.message}
openLinksInNewTab={true}
data-test-subj="errorMessageMarkdown"
/>
</EuiPopover>
);
};
import { ControlError } from './control_error_component';
export interface ControlFrameProps {
customPrepend?: JSX.Element;
@ -82,7 +41,6 @@ export const ControlFrame = ({
embeddableType,
}: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const [fatalError, setFatalError] = useState<Error>();
const controlGroup = useControlGroupContainer();
@ -107,19 +65,14 @@ export const ControlFrame = ({
const inputSubscription = embeddable
?.getInput$()
.subscribe((newInput) => setTitle(newInput.title));
const errorSubscription = embeddable?.getOutput$().subscribe({
error: setFatalError,
});
return () => {
inputSubscription?.unsubscribe();
errorSubscription?.unsubscribe();
};
}, [embeddable, embeddableRoot]);
const embeddableParentClassNames = classNames('controlFrame__control', {
'controlFrame--twoLine': controlStyle === 'twoLine',
'controlFrame--oneLine': controlStyle === 'oneLine',
'controlFrame--fatalError': !!fatalError,
});
function renderEmbeddablePrepend() {
@ -149,18 +102,13 @@ export const ControlFrame = ({
</>
}
>
{embeddable && !fatalError && (
{embeddable && (
<div
className={embeddableParentClassNames}
id={`controlFrame--${embeddableId}`}
ref={embeddableRoot}
>
{fatalError && <ControlFrameError error={fatalError} />}
</div>
)}
{fatalError && (
<div className={embeddableParentClassNames} id={`controlFrame--${embeddableId}`}>
{<ControlFrameError error={fatalError} />}
{isErrorEmbeddable(embeddable) && <ControlError error={embeddable.error} />}
</div>
)}
{!embeddable && (

View file

@ -205,11 +205,4 @@ $controlMinWidth: $euiSize * 14;
&--twoLine {
top: (-$euiSizeXS) !important;
}
&--fatalError {
padding: $euiSizeXS;
border-radius: $euiBorderRadius;
background-color: $euiColorEmptyShade;
box-shadow: 0 0 0 1px $euiColorLightShade;
}
}

View file

@ -19,6 +19,7 @@ import { OptionsListPopover } from './options_list_popover';
import { useOptionsList } from '../embeddable/options_list_embeddable';
import './options_list.scss';
import { ControlError } from '../../control_group/component/control_error_component';
export const OptionsListControl = ({
typeaheadSubject,
@ -31,6 +32,7 @@ export const OptionsListControl = ({
const optionsList = useOptionsList();
const dimensions = useResizeObserver(resizeRef.current);
const error = optionsList.select((state) => state.componentState.error);
const isPopoverOpen = optionsList.select((state) => state.componentState.popoverOpen);
const validSelections = optionsList.select((state) => state.componentState.validSelections);
const invalidSelections = optionsList.select((state) => state.componentState.invalidSelections);
@ -143,7 +145,9 @@ export const OptionsListControl = ({
</div>
);
return (
return error ? (
<ControlError error={error} />
) : (
<EuiFilterGroup
className={classNames('optionsList--filterGroup', {
'optionsList--filterGroupSingle': controlStyle !== 'twoLine',

View file

@ -247,7 +247,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
})
);
} catch (e) {
this.onFatalError(e);
this.dispatch.setErrorMessage(e.message);
}
this.dispatch.setDataViewId(this.dataView?.id);
@ -267,7 +267,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.field = originalField.toSpec();
} catch (e) {
this.onFatalError(e);
this.dispatch.setErrorMessage(e.message);
}
this.dispatch.setField(this.field);
}
@ -331,7 +331,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
// from prematurely setting loading to `false` and updating the suggestions to show "No results"
return;
}
this.onFatalError(response.error);
this.dispatch.setErrorMessage(response.error.message);
return;
}
@ -365,11 +365,13 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
// publish filter
const newFilters = await this.buildFilter();
batch(() => {
this.dispatch.setErrorMessage(undefined);
this.dispatch.setLoading(false);
this.dispatch.publishFilters(newFilters);
});
} else {
batch(() => {
this.dispatch.setErrorMessage(undefined);
this.dispatch.updateQueryResults({
availableOptions: [],
});
@ -412,14 +414,6 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
this.runOptionsListQuery();
};
public onFatalError = (e: Error) => {
batch(() => {
this.dispatch.setLoading(false);
this.dispatch.setPopoverOpen(false);
});
super.onFatalError(e);
};
public destroy = () => {
super.destroy();
this.cleanupStateTools();
@ -433,6 +427,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
ReactDOM.unmountComponentAtNode(this.node);
}
this.node = node;
ReactDOM.render(
<KibanaThemeProvider theme$={pluginServices.getServices().theme.theme$}>
<OptionsListEmbeddableContext.Provider value={this}>

View file

@ -103,6 +103,12 @@ export const optionsListReducers = {
state.componentState.invalidSelections = invalidSelections;
state.componentState.validSelections = validSelections;
},
setErrorMessage: (
state: WritableDraft<OptionsListReduxState>,
action: PayloadAction<string | undefined>
) => {
state.componentState.error = action.payload;
},
setLoading: (state: WritableDraft<OptionsListReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},

View file

@ -33,6 +33,7 @@ export interface OptionsListComponentState {
totalCardinality?: number;
popoverOpen: boolean;
field?: FieldSpec;
error?: string;
}
// public only - redux embeddable state type

View file

@ -21,6 +21,7 @@ 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';
@ -32,7 +33,9 @@ export const RangeSliderControl: FC = () => {
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 id = rangeSlider.select((state) => state.explicitInput.id);
const value = rangeSlider.select((state) => state.explicitInput.value) ?? ['', ''];
const isLoading = rangeSlider.select((state) => state.output.loading);
@ -116,7 +119,9 @@ export const RangeSliderControl: FC = () => {
</button>
);
return (
return error ? (
<ControlError error={error} />
) : (
<EuiInputPopover
input={button}
isOpen={isPopoverOpen}

View file

@ -136,12 +136,19 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.setInitializationFinished();
}
this.runRangeSliderQuery().then(async () => {
if (initialValue) {
this.setInitializationFinished();
}
this.setupSubscriptions();
});
this.runRangeSliderQuery()
.catch((e) => {
batch(() => {
this.dispatch.setLoading(false);
this.dispatch.setErrorMessage(e.message);
});
})
.then(async () => {
if (initialValue) {
this.setInitializationFinished();
}
this.setupSubscriptions();
});
};
private setupSubscriptions = () => {
@ -161,7 +168,13 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
);
// fetch available min/max when input changes
this.subscriptions.add(dataFetchPipe.subscribe(this.runRangeSliderQuery));
this.subscriptions.add(
dataFetchPipe.subscribe(async () =>
this.runRangeSliderQuery().catch((e) => {
this.dispatch.setErrorMessage(e.message);
})
)
);
// build filters when value change
this.subscriptions.add(
@ -186,7 +199,6 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
if (!this.dataView || this.dataView.id !== dataViewId) {
try {
this.dataView = await this.dataViewsService.get(dataViewId);
if (!this.dataView) {
throw new Error(
i18n.translate('controls.rangeSlider.errors.dataViewNotFound', {
@ -195,27 +207,28 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
})
);
}
this.dispatch.setDataViewId(this.dataView.id);
} catch (e) {
this.onFatalError(e);
this.dispatch.setErrorMessage(e.message);
}
}
if (!this.field || this.field.name !== fieldName) {
this.field = this.dataView?.getFieldByName(fieldName);
if (this.field === undefined) {
this.onFatalError(
new Error(
if (this.dataView && (!this.field || this.field.name !== fieldName)) {
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 },
})
)
);
}
);
}
this.dispatch.setField(this.field?.toSpec());
this.dispatch.setField(this.field?.toSpec());
} catch (e) {
this.dispatch.setErrorMessage(e.message);
}
}
return { dataView: this.dataView, field: this.field! };
@ -223,6 +236,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
private runRangeSliderQuery = async () => {
this.dispatch.setLoading(true);
const { dataView, field } = await this.getCurrentDataViewAndField();
if (!dataView || !field) return;
@ -268,15 +282,18 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
field,
filters,
query,
}).catch((e) => {
throw e;
});
this.dispatch.setMinMax({
min: `${min ?? ''}`,
max: `${max ?? ''}`,
});
// build filter with new min/max
await this.buildFilter();
await this.buildFilter().catch((e) => {
throw e;
});
};
private fetchMinMax = async ({
@ -321,11 +338,11 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
min: aggBody,
},
};
searchSource.setField('aggs', aggs);
const resp = await lastValueFrom(searchSource.fetch$());
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', '');
@ -343,7 +360,6 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
value: [selectedMin, selectedMax] = ['', ''],
},
} = this.getState();
const hasData = !isEmpty(availableMin) && !isEmpty(availableMax);
const hasLowerSelection = !isEmpty(selectedMin);
const hasUpperSelection = !isEmpty(selectedMax);
@ -358,6 +374,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.dispatch.setIsInvalid(!ignoreParentSettings?.ignoreValidations && hasEitherSelection);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([]);
this.dispatch.setErrorMessage(undefined);
});
return;
}
@ -413,6 +430,7 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.dispatch.setIsInvalid(true);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([]);
this.dispatch.setErrorMessage(undefined);
});
return;
}
@ -423,11 +441,14 @@ export class RangeSliderEmbeddable extends Embeddable<RangeSliderEmbeddableInput
this.dispatch.setIsInvalid(false);
this.dispatch.setDataViewId(dataView.id);
this.dispatch.publishFilters([rangeFilter]);
this.dispatch.setErrorMessage(undefined);
});
};
public reload = () => {
this.runRangeSliderQuery();
this.runRangeSliderQuery().catch((e) => {
this.dispatch.setErrorMessage(e.message);
});
};
public destroy = () => {

View file

@ -40,6 +40,12 @@ export const rangeSliderReducers = {
) => {
state.output.dataViewId = action.payload;
},
setErrorMessage: (
state: WritableDraft<RangeSliderReduxState>,
action: PayloadAction<string | undefined>
) => {
state.componentState.error = action.payload;
},
setLoading: (state: WritableDraft<RangeSliderReduxState>, action: PayloadAction<boolean>) => {
state.output.loading = action.payload;
},

View file

@ -17,6 +17,7 @@ export interface RangeSliderComponentState {
field?: FieldSpec;
min: string;
max: string;
error?: string;
isInvalid?: boolean;
}

View file

@ -277,6 +277,11 @@ export abstract class Embeddable<
}
}
/**
* Call this **only** when your embeddable has encountered a non-recoverable error; recoverable errors
* should be handled by the individual embeddable types
* @param e The fatal, unrecoverable Error that was thrown
*/
protected onFatalError(e: Error) {
this.fatalError = e;
this.outputSubject.error(e);

View file

@ -64,7 +64,6 @@ export class RenderCompleteDispatcher {
if (!this.el) return;
this.count++;
this.el.setAttribute('data-render-complete', 'true');
this.el.setAttribute('data-loading', 'false');
this.el.setAttribute('data-rendering-count', String(this.count));
}

View file

@ -6,9 +6,7 @@
.fieldPickerSelectable {
height: $euiSizeXXL * 9; // 40 * 9 = 360px
&.fieldPickerSelectableLoading {
.euiSelectableMessage {
height: 100%;
}
.euiSelectableMessage {
height: 100%;
}
}

View file

@ -16,7 +16,6 @@ import { FtrProviderContext } from '../../../../ftr_provider_context';
import { OPTIONS_LIST_DASHBOARD_NAME } from '.';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const queryBar = getService('queryBar');
const pieChart = getService('pieChart');
const elasticChart = getService('elasticChart');
@ -45,6 +44,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await elasticChart.setNewChartUiDebugFlag();
await dashboard.loadSavedDashboard(OPTIONS_LIST_DASHBOARD_NAME);
await dashboard.ensureDashboardIsInEditMode();
await header.waitUntilLoadingHasFinished();
};
before(async () => {
@ -67,12 +67,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('Applies query settings to controls', async () => {
it('Applies dashboard query to options list control', async () => {
await queryBar.setQuery('animal.keyword : "dog" ');
it('Malformed query throws an error', async () => {
await queryBar.setQuery('animal.keyword : "dog" error');
await queryBar.submitQuery(); // quicker than clicking the submit button, but hides the time picker
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('control-frame-error');
});
it('Can recover from malformed query error', async () => {
await queryBar.setQuery('animal.keyword : "dog"');
await queryBar.submitQuery();
await header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('control-frame-error');
});
it('Applies dashboard query to options list control', async () => {
const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [
'ruff',
'bark',
@ -85,7 +94,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
invalidSelections: [],
});
await queryBar.setQuery('');
await queryBar.clickQuerySubmitButton(); // ensures that the time picker is visible for the next test
await queryBar.clickQuerySubmitButton(); // slower than submitQuery but ensures that the time picker is visible for the next test
});
it('Applies dashboard time range to options list control', async () => {
@ -94,7 +103,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'Jan 1, 2017 @ 00:00:00.000',
'Jan 1, 2017 @ 00:00:00.000'
);
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
@ -110,7 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
operation: 'is one of',
value: ['bark', 'bow ow ow', 'ruff'],
});
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
});
@ -128,20 +135,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Does not apply disabled dashboard filters to options list control', async () => {
await filterBar.toggleFilterEnabled('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.ensureAvailableOptionsEqual(controlId, {
suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS,
invalidSelections: [],
});
await filterBar.toggleFilterEnabled('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
});
it('Negated filters apply to options control', async () => {
await filterBar.toggleFilterNegated('sound.keyword');
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
const suggestions = pick(OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS, [
@ -167,7 +171,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('Shows available options in options list', async () => {
await queryBar.setQuery('');
await queryBar.submitQuery();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.ensureAvailableOptionsEqual(controlId, {
suggestions: OPTIONS_LIST_ANIMAL_SOUND_SUGGESTIONS,
@ -218,16 +221,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
it('Applies options list control options to dashboard', async () => {
await retry.try(async () => {
expect(await pieChart.getPieSliceCount()).to.be(2);
});
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(2);
});
it('Applies options list control options to dashboard by default on open', async () => {
await dashboard.gotoDashboardLandingPage();
await header.waitUntilLoadingHasFinished();
await dashboard.clickUnsavedChangesContinueEditing(OPTIONS_LIST_DASHBOARD_NAME);
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(2);
const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId);
@ -236,13 +238,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('excluding selections has expected results', async () => {
await dashboard.clickQuickSave();
await dashboard.waitForRenderComplete();
await header.waitUntilLoadingHasFinished();
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(false);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(5);
await dashboard.clearUnsavedChanges();
});
@ -251,8 +253,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSetIncludeSelections(true);
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieSliceCount()).to.be(2);
await dashboard.clearUnsavedChanges();
});
@ -339,8 +341,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('B');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
await dashboard.waitForRenderComplete();
await dashboard.waitForRenderComplete();
expect(await pieChart.getPieChartLabels()).to.eql(['bark', 'bow ow ow']);
});
@ -387,10 +389,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
controlId = (await dashboardControls.getAllControlIds())[0];
await header.waitUntilLoadingHasFinished();
await dashboard.waitForRenderComplete();
});
it('creating exists query has expected results', async () => {
await dashboard.waitForRenderComplete();
expect((await pieChart.getPieChartValues())[0]).to.be(6);
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectExists();

View file

@ -15,10 +15,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const esArchiver = getService('esArchiver');
const security = getService('security');
const queryBar = getService('queryBar');
const filterBar = getService('filterBar');
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
const { dashboardControls, timePicker, common, dashboard } = getPageObjects([
const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
@ -231,5 +232,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect((await testSubjects.getVisibleText('rangeSlider__helpText')).length).to.be.above(0);
});
});
describe('interaction', async () => {
it('Malformed query throws an error', async () => {
await queryBar.setQuery('AvgTicketPrice <= 300 error');
await queryBar.submitQuery();
await header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('control-frame-error');
});
it('Can recover from malformed query error', async () => {
await queryBar.setQuery('AvgTicketPrice <= 300');
await queryBar.submitQuery();
await header.waitUntilLoadingHasFinished();
await testSubjects.missingOrFail('control-frame-error');
});
it('Applies dashboard query to range slider control', async () => {
const firstId = (await dashboardControls.getAllControlIds())[0];
await dashboardControls.rangeSliderWaitForLoading();
await dashboardControls.validateRange('placeholder', firstId, '100', '300');
await queryBar.setQuery('');
await queryBar.submitQuery();
});
});
});
}