mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:**  - **After:**  ### 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)  - [Range slider (`test/functional/apps/dashboard_elements/controls/range_slider.ts`)](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2317)  ### 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:
parent
ccf0099470
commit
3e3419d6c9
16 changed files with 194 additions and 128 deletions
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 && (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -33,6 +33,7 @@ export interface OptionsListComponentState {
|
|||
totalCardinality?: number;
|
||||
popoverOpen: boolean;
|
||||
field?: FieldSpec;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// public only - redux embeddable state type
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -17,6 +17,7 @@ export interface RangeSliderComponentState {
|
|||
field?: FieldSpec;
|
||||
min: string;
|
||||
max: string;
|
||||
error?: string;
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -6,9 +6,7 @@
|
|||
.fieldPickerSelectable {
|
||||
height: $euiSizeXXL * 9; // 40 * 9 = 360px
|
||||
|
||||
&.fieldPickerSelectableLoading {
|
||||
.euiSelectableMessage {
|
||||
height: 100%;
|
||||
}
|
||||
.euiSelectableMessage {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue