diff --git a/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx index 43723ecc06984..8beb793813d54 100644 --- a/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx @@ -35,6 +35,7 @@ export function ReactExpressionRenderer({ dataAttrs, padding, renderError, + abortController, ...expressionRendererOptions }: ReactExpressionRendererProps) { const nodeRef = useRef(null); diff --git a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts index 865a3ef21fb6f..efe089983b639 100644 --- a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts +++ b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts @@ -36,6 +36,7 @@ export interface ExpressionRendererParams extends IExpressionLoaderParams { * An observable which can be used to re-run the expression without destroying the component */ reload$?: Observable; + abortController?: AbortController; } interface ExpressionRendererState { @@ -54,6 +55,7 @@ export function useExpressionRenderer( onEvent, onRender$, reload$, + abortController, ...loaderParams }: ExpressionRendererParams ): ExpressionRendererState { @@ -77,6 +79,13 @@ export function useExpressionRenderer( // will call done() in LayoutEffect when done with rendering custom error state const errorRenderHandlerRef = useRef(null); + useEffect(() => { + if (abortController?.signal) + abortController.signal.onabort = () => { + expressionLoaderRef.current?.cancel(); + }; + }, [abortController]); + /* eslint-disable react-hooks/exhaustive-deps */ // OK to ignore react-hooks/exhaustive-deps because options update is handled by calling .update() useEffect(() => { diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 27090f36fdc7c..23a17fbcea1dc 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -59,7 +59,7 @@ export interface IExpressionLoaderParams { hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; getCompatibleCellValueActions?: ExpressionRenderHandlerParams['getCompatibleCellValueActions']; executionContext?: KibanaExecutionContext; - + abortController?: AbortController; /** * The flag to toggle on emitting partial results. * By default, the partial results are disabled. diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index 010e1cfa928c7..cfc096e8f197f 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -47,6 +47,7 @@ import { useRefetch } from './hooks/use_refetch'; import { useEditVisualization } from './hooks/use_edit_visualization'; export interface ChartProps { + abortController?: AbortController; isChartAvailable: boolean; hiddenPanel?: boolean; className?: string; @@ -123,6 +124,7 @@ export function Chart({ onFilter, onBrushEnd, withDefaultActions, + abortController, }: ChartProps) { const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); @@ -406,6 +408,7 @@ export function Chart({ /> )} (); const [chartSize, setChartSize] = useState('100%'); @@ -223,6 +225,7 @@ export function Histogram({ > ; /** diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index b132dccf94298..7d1cb9f5e9064 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -183,6 +183,7 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren withDefaultActions?: EmbeddableComponentProps['withDefaultActions']; table?: Datatable; + abortController?: AbortController; } export const UnifiedHistogramLayout = ({ @@ -223,6 +224,7 @@ export const UnifiedHistogramLayout = ({ onBrushEnd, children, withDefaultActions, + abortController, }: UnifiedHistogramLayoutProps) => { const { allSuggestions, @@ -308,6 +310,7 @@ export const UnifiedHistogramLayout = ({ <> { const QUERY_INPUT_SELECTOR = 'QueryStringInputUI'; const TIMEPICKER_SELECTOR = 'Memo(EuiSuperDatePicker)'; const REFRESH_BUTTON_SELECTOR = 'EuiSuperUpdateButton'; + const CANCEL_BUTTON_SELECTOR = '[data-test-subj="queryCancelButton"]'; const TIMEPICKER_DURATION = '[data-shared-timefilter-duration]'; const TEXT_BASED_EDITOR = '[data-test-subj="unifiedTextLangEditor"]'; @@ -396,6 +397,42 @@ describe('QueryBarTopRowTopRow', () => { expect(getByTestId('dataViewPickerOverride')).toBeInTheDocument(); }); + + it('Should render cancel button when loading', () => { + const component = mount( + wrapQueryBarTopRowInContext({ + isLoading: true, + onCancel: () => {}, + isDirty: false, + screenTitle: 'Another Screen', + showDatePicker: true, + showSubmitButton: true, + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + timeHistory: mockTimeHistory, + }) + ); + + expect(component.find(CANCEL_BUTTON_SELECTOR).length).not.toBe(0); + }); + + it('Should NOT render cancel button when not loading', () => { + const component = mount( + wrapQueryBarTopRowInContext({ + isLoading: false, + onCancel: () => {}, + isDirty: false, + screenTitle: 'Another Screen', + showDatePicker: true, + showSubmitButton: true, + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + timeHistory: mockTimeHistory, + }) + ); + + expect(component.find(CANCEL_BUTTON_SELECTOR).length).toBe(0); + }); }); describe('SharingMetaFields', () => { diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index 4b63ed3e1ad28..0e4020f5c70fa 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -33,6 +33,8 @@ import { useIsWithinBreakpoints, EuiSuperUpdateButton, EuiToolTip, + EuiButton, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TimeHistoryContract, getQueryLog } from '@kbn/data-plugin/public'; @@ -67,6 +69,10 @@ export const strings = { i18n.translate('unifiedSearch.queryBarTopRow.submitButton.refresh', { defaultMessage: 'Refresh query', }), + getCancelQueryLabel: () => + i18n.translate('unifiedSearch.queryBarTopRow.submitButton.cancel', { + defaultMessage: 'Cancel', + }), getRunQueryLabel: () => i18n.translate('unifiedSearch.queryBarTopRow.submitButton.run', { defaultMessage: 'Run query', @@ -127,6 +133,7 @@ export interface QueryBarTopRowProps onRefresh?: (payload: { dateRange: TimeRange }) => void; onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; onSubmit: (payload: { dateRange: TimeRange; query?: Query | QT }) => void; + onCancel?: () => void; placeholder?: string; prepend?: React.ComponentProps['prepend']; query?: Query | QT; @@ -282,6 +289,7 @@ export const QueryBarTopRow = React.memo( dateRangeRef.current = currentDateRange; const propsOnSubmit = props.onSubmit; + const propsOnCancel = props.onCancel; const toRecentlyUsedRanges = (ranges: TimeRange[]) => ranges.map(({ from, to }: { from: string; to: string }) => { @@ -339,6 +347,20 @@ export const QueryBarTopRow = React.memo( [persistedLog, onSubmit] ); + const onClickCancelButton = useCallback( + (event: React.MouseEvent) => { + if (persistedLog && queryRef.current && isOfQueryType(queryRef.current)) { + persistedLog.add(queryRef.current.query); + } + event.preventDefault(); + + if (propsOnCancel) { + propsOnCancel(); + } + }, + [persistedLog, propsOnCancel] + ); + const propsOnChange = props.onChange; const onQueryChange = useCallback( (query: Query) => { @@ -486,6 +508,39 @@ export const QueryBarTopRow = React.memo( return {component}; } + function renderCancelButton() { + const buttonLabelCancel = strings.getCancelQueryLabel(); + + if (submitButtonIconOnly) { + return ( + + {buttonLabelCancel} + + ); + } + + return ( + + {buttonLabelCancel} + + ); + } + function renderUpdateButton() { if (!shouldRenderUpdatebutton() && !shouldRenderDatePicker()) { return null; @@ -501,25 +556,28 @@ export const QueryBarTopRow = React.memo( React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) ) : ( - + {props.isLoading && propsOnCancel && renderCancelButton()} + {(!props.isLoading || !propsOnCancel) && ( + + )} ); diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index f1f631494dd53..86a63eb043a18 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -234,6 +234,8 @@ export function createSearchBar({ dateRangeTo={timeRange.to} refreshInterval={refreshInterval.value} isRefreshPaused={refreshInterval.pause} + isLoading={props.isLoading} + onCancel={props.onCancel} filters={filters} query={query} onFiltersUpdated={defaultFiltersUpdated(data.query, props.onFiltersUpdated)} diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index 7d3c712181c0e..a8a81224df534 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -86,6 +86,7 @@ export interface SearchBarOwnProps { onClearSavedQuery?: () => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; + onCancel?: () => void; // Autorefresh onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; indicateNoData?: boolean; @@ -582,6 +583,7 @@ class SearchBarUI extends C isDisabled={this.props.isDisabled} onRefresh={this.props.onRefresh} onRefreshChange={this.props.onRefreshChange} + onCancel={this.props.onCancel} onChange={this.onQueryBarChange} isDirty={this.isDirty()} customSubmitButton={ diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 3eaa4e0072ad1..f2fa31b1aa474 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -184,6 +184,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput { onTableRowClick?: ( data: Simplify ) => void; + abortController?: AbortController; } export type LensByValueInput = { @@ -1146,6 +1147,7 @@ export class Embeddable className={input.className} style={input.style} executionContext={this.getExecutionContext()} + abortController={this.input.abortController} addUserMessages={(messages) => this.addUserMessages(messages)} onRuntimeError={(error) => { this.updateOutput({ error }); diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index bdbcfb49617bb..f433f71d453b8 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -85,6 +85,7 @@ export type EmbeddableComponentProps = (TypedLensByValueInput | LensByReferenceI withDefaultActions?: boolean; extraActions?: Action[]; showInspector?: boolean; + abortController?: AbortController; }; export type EmbeddableComponent = React.ComponentType; @@ -142,6 +143,7 @@ interface EmbeddablePanelWrapperProps { extraActions?: Action[]; showInspector?: boolean; withDefaultActions?: boolean; + abortController?: AbortController; } const EmbeddablePanelWrapper: FC = ({ @@ -152,6 +154,7 @@ const EmbeddablePanelWrapper: FC = ({ extraActions, showInspector = true, withDefaultActions, + abortController, }) => { const [embeddable, loading] = useEmbeddableFactory({ factory, input }); useEffect(() => { diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index 897b5c99e63d5..d28d6fd3b527e 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -47,6 +47,7 @@ export interface ExpressionWrapperProps { lensInspector: LensInspector; noPadding?: boolean; shouldUseSizeTransitionVeil?: boolean; + abortController?: AbortController; } export function ExpressionWrapper({ @@ -73,6 +74,7 @@ export function ExpressionWrapper({ lensInspector, noPadding, shouldUseSizeTransitionVeil, + abortController, }: ExpressionWrapperProps) { if (!expression) return null; return ( @@ -94,6 +96,7 @@ export function ExpressionWrapper({ syncTooltips={syncTooltips} syncCursor={syncCursor} executionContext={executionContext} + abortController={abortController} shouldUseSizeTransitionVeil={shouldUseSizeTransitionVeil ?? true} renderError={(errorMessage, error) => { const messages = getOriginalRequestErrorMessages(error || null);