mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[embeddable refactor] complete fetch context interface (#179776)
Closes https://github.com/elastic/kibana/issues/179838 1. define `PublishesSearchSource` interface 2. define `PublishesReload` interface 3. Add optional `timeslice$` to `PublishesTimeRange` interface 4. Update `DashboardContainer` to implement `PublishesSearchSource`, `PublishesReload`, and `PublishesTimeRange` interfaces 5. `onFetchContextChanged` utility method created to consolidate fetch context logic into a single re-usable component. ### Test instructions 1. Start kibana with `yarn start --run-examples` 2. Install sample web logs data set 3. create new dashboard, add `Unified search example` panel 4. Change time range, filters, search and verify panel fetches count for narrowed data range 5. add timeslider control and advance timeslider, verify panel fetches count for timeslice 6. Click reload, verify panel reloads count --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
8547ab2a00
commit
e23335ba8b
14 changed files with 630 additions and 291 deletions
|
@ -9,8 +9,13 @@
|
|||
import { EuiCallOut } from '@elastic/eui';
|
||||
import { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
|
||||
import { initializeTimeRange, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
FetchContext,
|
||||
initializeTimeRange,
|
||||
onFetchContextChanged,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import React, { useEffect } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { SEARCH_EMBEDDABLE_ID } from './constants';
|
||||
import { getCount } from './get_count';
|
||||
|
@ -23,13 +28,7 @@ export const getSearchEmbeddableFactory = (services: Services) => {
|
|||
return state.rawState as State;
|
||||
},
|
||||
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
|
||||
const {
|
||||
appliedTimeRange$,
|
||||
cleanupTimeRange,
|
||||
serializeTimeRange,
|
||||
timeRangeApi,
|
||||
timeRangeComparators,
|
||||
} = initializeTimeRange(state, parentApi);
|
||||
const timeRange = initializeTimeRange(state);
|
||||
const defaultDataView = await services.dataViews.getDefaultDataView();
|
||||
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(
|
||||
defaultDataView ? [defaultDataView] : undefined
|
||||
|
@ -38,67 +37,81 @@ export const getSearchEmbeddableFactory = (services: Services) => {
|
|||
|
||||
const api = buildApi(
|
||||
{
|
||||
...timeRangeApi,
|
||||
...timeRange.api,
|
||||
dataViews: dataViews$,
|
||||
dataLoading: dataLoading$,
|
||||
serializeState: () => {
|
||||
return {
|
||||
rawState: {
|
||||
...serializeTimeRange(),
|
||||
...timeRange.serialize(),
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
...timeRangeComparators,
|
||||
...timeRange.comparators,
|
||||
}
|
||||
);
|
||||
|
||||
let isUnmounted = false;
|
||||
const error$ = new BehaviorSubject<Error | undefined>(undefined);
|
||||
const count$ = new BehaviorSubject<number>(0);
|
||||
const onFetch = (fetchContext: FetchContext, isCanceled: () => boolean) => {
|
||||
error$.next(undefined);
|
||||
if (!defaultDataView) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(true);
|
||||
getCount(
|
||||
defaultDataView,
|
||||
services.data,
|
||||
fetchContext.filters ?? [],
|
||||
fetchContext.query,
|
||||
// timeRange and timeslice provided seperatly so consumers can decide
|
||||
// whether to refetch data for just mask current data.
|
||||
// In this example, we must refetch because we need a count within the time range.
|
||||
fetchContext.timeslice
|
||||
? {
|
||||
from: new Date(fetchContext.timeslice[0]).toISOString(),
|
||||
to: new Date(fetchContext.timeslice[1]).toISOString(),
|
||||
mode: 'absolute' as 'absolute',
|
||||
}
|
||||
: fetchContext.timeRange
|
||||
)
|
||||
.then((nextCount: number) => {
|
||||
if (isUnmounted || isCanceled()) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(false);
|
||||
count$.next(nextCount);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isUnmounted || isCanceled()) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(false);
|
||||
error$.next(err);
|
||||
});
|
||||
};
|
||||
const unsubscribeFromFetch = onFetchContextChanged({
|
||||
api,
|
||||
onFetch,
|
||||
fetchOnSetup: true,
|
||||
});
|
||||
|
||||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
const [filters, query, appliedTimeRange] = useBatchedPublishingSubjects(
|
||||
api.parentApi?.filters$,
|
||||
api.parentApi?.query$,
|
||||
appliedTimeRange$
|
||||
);
|
||||
const [count, error] = useBatchedPublishingSubjects(count$, error$);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupTimeRange();
|
||||
isUnmounted = true;
|
||||
unsubscribeFromFetch();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setError(undefined);
|
||||
if (!defaultDataView) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(true);
|
||||
getCount(defaultDataView, services.data, filters ?? [], query, appliedTimeRange)
|
||||
.then((nextCount: number) => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(false);
|
||||
setCount(nextCount);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
dataLoading$.next(false);
|
||||
setError(err);
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [filters, query, appliedTimeRange]);
|
||||
|
||||
if (!defaultDataView) {
|
||||
return (
|
||||
<EuiCallOut title="Default data view not found" color="warning" iconType="warning">
|
||||
|
|
|
@ -74,8 +74,12 @@ export {
|
|||
type PublishesTimeRange,
|
||||
type PublishesUnifiedSearch,
|
||||
type PublishesWritableUnifiedSearch,
|
||||
} from './interfaces/unified_search/publishes_unified_search';
|
||||
export { initializeTimeRange } from './interfaces/unified_search/initialize_time_range';
|
||||
} from './interfaces/fetch/publishes_unified_search';
|
||||
export { initializeTimeRange } from './interfaces/fetch/initialize_time_range';
|
||||
export {
|
||||
type FetchContext,
|
||||
onFetchContextChanged,
|
||||
} from './interfaces/fetch/on_fetch_context_changed';
|
||||
export {
|
||||
apiPublishesSavedObjectId,
|
||||
type PublishesSavedObjectId,
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject } from 'rxjs';
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { StateComparators } from '../../comparators';
|
||||
import { PublishesWritableTimeRange } from './publishes_unified_search';
|
||||
|
||||
export interface SerializedTimeRange {
|
||||
timeRange: TimeRange | undefined;
|
||||
}
|
||||
|
||||
export const initializeTimeRange = (
|
||||
rawState: SerializedTimeRange
|
||||
): {
|
||||
serialize: () => SerializedTimeRange;
|
||||
api: PublishesWritableTimeRange;
|
||||
comparators: StateComparators<SerializedTimeRange>;
|
||||
} => {
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(rawState.timeRange);
|
||||
function setTimeRange(nextTimeRange: TimeRange | undefined) {
|
||||
timeRange$.next(nextTimeRange);
|
||||
}
|
||||
|
||||
return {
|
||||
serialize: () => ({
|
||||
timeRange: timeRange$.value,
|
||||
}),
|
||||
comparators: {
|
||||
timeRange: [timeRange$, setTimeRange, fastIsEqual],
|
||||
} as StateComparators<SerializedTimeRange>,
|
||||
api: {
|
||||
timeRange$,
|
||||
setTimeRange,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* 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 { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { FetchContext, onFetchContextChanged } from './on_fetch_context_changed';
|
||||
|
||||
describe('onFetchContextChanged', () => {
|
||||
const onFetchMock = jest.fn();
|
||||
const parentApi = {
|
||||
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
|
||||
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
|
||||
reload$: new Subject<void>(),
|
||||
searchSessionId$: new BehaviorSubject<string | undefined>(undefined),
|
||||
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
|
||||
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
onFetchMock.mockReset();
|
||||
parentApi.filters$.next(undefined);
|
||||
parentApi.query$.next(undefined);
|
||||
parentApi.searchSessionId$.next(undefined);
|
||||
parentApi.timeRange$.next(undefined);
|
||||
parentApi.timeslice$.next(undefined);
|
||||
});
|
||||
|
||||
it('isCanceled should be true when onFetch triggered before previous onFetch finishes', async () => {
|
||||
const FETCH_TIMEOUT = 10;
|
||||
let calledCount = 0;
|
||||
let completedCallCount = 0;
|
||||
let completedContext: FetchContext | undefined;
|
||||
const onFetchInstrumented = async (context: FetchContext, isCanceled: () => boolean) => {
|
||||
calledCount++;
|
||||
await new Promise((resolve) => setTimeout(resolve, FETCH_TIMEOUT));
|
||||
if (isCanceled()) {
|
||||
return;
|
||||
}
|
||||
completedCallCount++;
|
||||
completedContext = context;
|
||||
};
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api: { parentApi },
|
||||
onFetch: onFetchInstrumented,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(calledCount).toBe(1);
|
||||
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-26h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(calledCount).toBe(2);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, FETCH_TIMEOUT));
|
||||
|
||||
expect(completedCallCount).toBe(1);
|
||||
expect(completedContext?.timeRange).toEqual({
|
||||
from: 'now-26h',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
describe('searchSessionId', () => {
|
||||
let i = 0;
|
||||
function setSearchSession() {
|
||||
i++;
|
||||
parentApi.searchSessionId$.next(`${i}`);
|
||||
}
|
||||
beforeEach(() => {
|
||||
i = 0;
|
||||
setSearchSession();
|
||||
});
|
||||
|
||||
it('should call onFetch a single time when fetch context changes', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api: { parentApi },
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
parentApi.filters$.next([]);
|
||||
parentApi.query$.next({ language: 'kquery', query: '' });
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
parentApi.timeslice$.next([0, 1]);
|
||||
setSearchSession();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext).toEqual({
|
||||
filters: [],
|
||||
isReload: true,
|
||||
query: {
|
||||
language: 'kquery',
|
||||
query: '',
|
||||
},
|
||||
searchSessionId: '2',
|
||||
timeRange: {
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
},
|
||||
timeslice: [0, 1],
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should call onFetch a single time with reload', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api: { parentApi },
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
parentApi.reload$.next();
|
||||
setSearchSession();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should call onFetch on local time range change and no search session change', async () => {
|
||||
const api = {
|
||||
parentApi,
|
||||
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
|
||||
};
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
api.timeRange$.next({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no searchSession$', () => {
|
||||
it('should call onFetch when reload triggered', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api: { parentApi },
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
parentApi.reload$.next();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.isReload).toBe(true);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
describe('local and parent time range', () => {
|
||||
const api = {
|
||||
parentApi,
|
||||
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
|
||||
};
|
||||
beforeEach(() => {
|
||||
api.timeRange$.next({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
api.parentApi.timeRange$.next({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onFetch with local time range', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should call onFetch when local time range changes', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
api.timeRange$.next({
|
||||
from: 'now-16m',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-16m',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should not call onFetch when parent time range changes', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
api.parentApi.timeRange$.next({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(0);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should call onFetch with parent time range when local time range is cleared', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
api.timeRange$.next(undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only parent time range', () => {
|
||||
const api = {
|
||||
parentApi,
|
||||
};
|
||||
beforeEach(() => {
|
||||
api.parentApi.timeRange$.next({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onFetch with parent time range', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('should call onFetch when parent time range changes', async () => {
|
||||
const unsubscribe = onFetchContextChanged({
|
||||
api,
|
||||
onFetch: onFetchMock,
|
||||
fetchOnSetup: false,
|
||||
});
|
||||
api.parentApi.timeRange$.next({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
expect(onFetchMock.mock.calls).toHaveLength(1);
|
||||
const fetchContext = onFetchMock.mock.calls[0][0];
|
||||
expect(fetchContext.timeRange).toEqual({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* 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 { debounce } from 'lodash';
|
||||
import { combineLatest, Observable, skip, Subscription } from 'rxjs';
|
||||
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import {
|
||||
apiPublishesTimeRange,
|
||||
apiPublishesUnifiedSearch,
|
||||
PublishesTimeRange,
|
||||
PublishesUnifiedSearch,
|
||||
} from './publishes_unified_search';
|
||||
import { apiPublishesSearchSession, PublishesSearchSession } from './publishes_search_session';
|
||||
import { apiHasParentApi, HasParentApi } from '../has_parent_api';
|
||||
import { apiPublishesReload } from './publishes_reload';
|
||||
|
||||
export interface FetchContext {
|
||||
isReload: boolean;
|
||||
filters: Filter[] | undefined;
|
||||
query: Query | AggregateQuery | undefined;
|
||||
searchSessionId: string | undefined;
|
||||
timeRange: TimeRange | undefined;
|
||||
timeslice: [number, number] | undefined;
|
||||
}
|
||||
|
||||
function getFetchContext(api: unknown, isReload: boolean) {
|
||||
const typeApi = api as Partial<
|
||||
PublishesTimeRange & HasParentApi<Partial<PublishesUnifiedSearch & PublishesSearchSession>>
|
||||
>;
|
||||
return {
|
||||
isReload,
|
||||
filters: typeApi?.parentApi?.filters$?.value,
|
||||
query: typeApi?.parentApi?.query$?.value,
|
||||
searchSessionId: typeApi?.parentApi?.searchSessionId$?.value,
|
||||
timeRange: typeApi?.timeRange$?.value ?? typeApi?.parentApi?.timeRange$?.value,
|
||||
timeslice: typeApi?.timeRange$?.value ? undefined : typeApi?.parentApi?.timeslice$?.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function onFetchContextChanged({
|
||||
api,
|
||||
onFetch,
|
||||
fetchOnSetup,
|
||||
}: {
|
||||
api: unknown;
|
||||
onFetch: (fetchContext: FetchContext, isCanceled: () => boolean) => void;
|
||||
fetchOnSetup: boolean;
|
||||
}): () => void {
|
||||
let fetchSymbol: symbol | undefined;
|
||||
const debouncedFetch = debounce(fetch, 0);
|
||||
function fetch(isReload: boolean = false) {
|
||||
const currentFetchSymbol = Symbol();
|
||||
fetchSymbol = currentFetchSymbol;
|
||||
onFetch(getFetchContext(api, isReload), () => fetchSymbol !== currentFetchSymbol);
|
||||
}
|
||||
|
||||
const subscriptions: Subscription[] = [];
|
||||
|
||||
if (apiPublishesTimeRange(api)) {
|
||||
subscriptions.push(
|
||||
api.timeRange$.pipe(skip(1)).subscribe(() => {
|
||||
debouncedFetch();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (apiHasParentApi(api) && apiPublishesSearchSession(api.parentApi)) {
|
||||
subscriptions.push(
|
||||
api.parentApi?.searchSessionId$.pipe(skip(1)).subscribe(() => {
|
||||
debouncedFetch.cancel();
|
||||
fetch(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (apiHasParentApi(api) && apiPublishesUnifiedSearch(api.parentApi)) {
|
||||
subscriptions.push(
|
||||
combineLatest([api.parentApi.filters$, api.parentApi.query$])
|
||||
.pipe(skip(1))
|
||||
.subscribe(() => {
|
||||
// Ignore change when searchSessionId is provided.
|
||||
if ((api?.parentApi as Partial<PublishesSearchSession>)?.searchSessionId$?.value) {
|
||||
return;
|
||||
}
|
||||
debouncedFetch();
|
||||
})
|
||||
);
|
||||
|
||||
if (apiHasParentApi(api) && apiPublishesTimeRange(api.parentApi)) {
|
||||
const timeObservables: Array<Observable<unknown>> = [api.parentApi.timeRange$];
|
||||
if (api.parentApi.timeslice$) {
|
||||
timeObservables.push(api.parentApi.timeslice$);
|
||||
}
|
||||
subscriptions.push(
|
||||
combineLatest(timeObservables)
|
||||
.pipe(skip(1))
|
||||
.subscribe(() => {
|
||||
// Ignore changes when searchSessionId is provided or local time range is provided.
|
||||
if (
|
||||
(api?.parentApi as Partial<PublishesSearchSession>)?.searchSessionId$?.value ||
|
||||
(api as Partial<PublishesTimeRange>).timeRange$?.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
debouncedFetch();
|
||||
})
|
||||
);
|
||||
}
|
||||
if (apiHasParentApi(api) && apiPublishesReload(api.parentApi)) {
|
||||
subscriptions.push(
|
||||
api.parentApi.reload$.subscribe(() => {
|
||||
// Ignore changes when searchSessionId is provided
|
||||
if ((api?.parentApi as Partial<PublishesSearchSession>)?.searchSessionId$?.value) {
|
||||
return;
|
||||
}
|
||||
debouncedFetch.cancel();
|
||||
fetch(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchOnSetup) {
|
||||
debouncedFetch();
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((subcription) => subcription.unsubscribe());
|
||||
};
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* 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 { PublishingSubject } from '../../publishing_subject';
|
||||
|
||||
export interface PublishesReload {
|
||||
reload$: PublishingSubject<void>;
|
||||
}
|
||||
|
||||
export const apiPublishesReload = (unknownApi: null | unknown): unknownApi is PublishesReload => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesReload)?.reload$ !== undefined);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { PublishingSubject } from '../../publishing_subject';
|
||||
|
||||
export interface PublishesSearchSession {
|
||||
searchSessionId$: PublishingSubject<string | undefined>;
|
||||
}
|
||||
|
||||
export const apiPublishesSearchSession = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesSearchSession => {
|
||||
return Boolean(
|
||||
unknownApi && (unknownApi as PublishesSearchSession)?.searchSessionId$ !== undefined
|
||||
);
|
||||
};
|
|
@ -11,6 +11,7 @@ import { PublishingSubject } from '../../publishing_subject';
|
|||
|
||||
export interface PublishesTimeRange {
|
||||
timeRange$: PublishingSubject<TimeRange | undefined>;
|
||||
timeslice$?: PublishingSubject<[number, number] | undefined>;
|
||||
}
|
||||
|
||||
export type PublishesWritableTimeRange = PublishesTimeRange & {
|
|
@ -1,148 +0,0 @@
|
|||
/*
|
||||
* 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 { TimeRange } from '@kbn/es-query';
|
||||
import { BehaviorSubject, skip } from 'rxjs';
|
||||
import { initializeTimeRange } from './initialize_time_range';
|
||||
|
||||
describe('initialize time range', () => {
|
||||
describe('appliedTimeRange$', () => {
|
||||
let timeRange: TimeRange | undefined;
|
||||
const parentApi = {
|
||||
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
|
||||
};
|
||||
|
||||
describe('local and parent time range', () => {
|
||||
beforeEach(() => {
|
||||
timeRange = {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
};
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return local time range', () => {
|
||||
const { appliedTimeRange$ } = initializeTimeRange({ timeRange }, parentApi);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit when local time range changes', async () => {
|
||||
let emitCount = 0;
|
||||
const { appliedTimeRange$, timeRangeApi } = initializeTimeRange({ timeRange }, parentApi);
|
||||
|
||||
const subscribe = appliedTimeRange$.pipe(skip(1)).subscribe(() => {
|
||||
emitCount++;
|
||||
});
|
||||
|
||||
timeRangeApi.setTimeRange({
|
||||
from: 'now-16m',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(emitCount).toBe(1);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-16m',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
subscribe.unsubscribe();
|
||||
});
|
||||
|
||||
it('should not emit when parent time range changes', async () => {
|
||||
let emitCount = 0;
|
||||
const { appliedTimeRange$ } = initializeTimeRange({ timeRange }, parentApi);
|
||||
|
||||
const subscribe = appliedTimeRange$.pipe(skip(1)).subscribe(() => {
|
||||
emitCount++;
|
||||
});
|
||||
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(emitCount).toBe(0);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
subscribe.unsubscribe();
|
||||
});
|
||||
|
||||
it('should emit parent time range when local time range is cleared', async () => {
|
||||
let emitCount = 0;
|
||||
const { appliedTimeRange$, timeRangeApi } = initializeTimeRange({ timeRange }, parentApi);
|
||||
|
||||
const subscribe = appliedTimeRange$.pipe(skip(1)).subscribe(() => {
|
||||
emitCount++;
|
||||
});
|
||||
|
||||
timeRangeApi.setTimeRange(undefined);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(emitCount).toBe(1);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
subscribe.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only parent time range', () => {
|
||||
beforeEach(() => {
|
||||
timeRange = undefined;
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return parent time range', () => {
|
||||
const { appliedTimeRange$ } = initializeTimeRange({ timeRange }, parentApi);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit when parent time range changes', async () => {
|
||||
let emitCount = 0;
|
||||
const { appliedTimeRange$ } = initializeTimeRange({ timeRange }, parentApi);
|
||||
|
||||
const subscribe = appliedTimeRange$.pipe(skip(1)).subscribe(() => {
|
||||
emitCount++;
|
||||
});
|
||||
|
||||
parentApi.timeRange$.next({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(emitCount).toBe(1);
|
||||
expect(appliedTimeRange$.value).toEqual({
|
||||
from: 'now-25h',
|
||||
to: 'now',
|
||||
});
|
||||
|
||||
subscribe.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject } from 'rxjs';
|
||||
import fastIsEqual from 'fast-deep-equal';
|
||||
import { TimeRange } from '@kbn/es-query';
|
||||
import { PublishingSubject } from '../../publishing_subject';
|
||||
import { StateComparators } from '../../comparators';
|
||||
import {
|
||||
apiPublishesTimeRange,
|
||||
PublishesTimeRange,
|
||||
PublishesWritableTimeRange,
|
||||
} from './publishes_unified_search';
|
||||
|
||||
export interface SerializedTimeRange {
|
||||
timeRange: TimeRange | undefined;
|
||||
}
|
||||
|
||||
export const initializeTimeRange = (
|
||||
rawState: SerializedTimeRange,
|
||||
parentApi?: unknown
|
||||
): {
|
||||
appliedTimeRange$: PublishingSubject<TimeRange | undefined>;
|
||||
cleanupTimeRange: () => void;
|
||||
serializeTimeRange: () => SerializedTimeRange;
|
||||
timeRangeApi: PublishesWritableTimeRange;
|
||||
timeRangeComparators: StateComparators<SerializedTimeRange>;
|
||||
} => {
|
||||
const timeRange$ = new BehaviorSubject<TimeRange | undefined>(rawState.timeRange);
|
||||
function setTimeRange(nextTimeRange: TimeRange | undefined) {
|
||||
timeRange$.next(nextTimeRange);
|
||||
}
|
||||
const appliedTimeRange$ = new BehaviorSubject(
|
||||
timeRange$.value ?? (parentApi as Partial<PublishesTimeRange>)?.timeRange$?.value
|
||||
);
|
||||
|
||||
const subscriptions = timeRange$.subscribe((timeRange) => {
|
||||
appliedTimeRange$.next(
|
||||
timeRange ?? (parentApi as Partial<PublishesTimeRange>)?.timeRange$?.value
|
||||
);
|
||||
});
|
||||
if (apiPublishesTimeRange(parentApi)) {
|
||||
subscriptions.add(
|
||||
parentApi?.timeRange$.subscribe((parentTimeRange) => {
|
||||
if (timeRange$?.value) {
|
||||
return;
|
||||
}
|
||||
appliedTimeRange$.next(parentTimeRange);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
appliedTimeRange$,
|
||||
cleanupTimeRange: () => {
|
||||
subscriptions.unsubscribe();
|
||||
},
|
||||
serializeTimeRange: () => ({
|
||||
timeRange: timeRange$.value,
|
||||
}),
|
||||
timeRangeComparators: {
|
||||
timeRange: [timeRange$, setTimeRange, fastIsEqual],
|
||||
} as StateComparators<SerializedTimeRange>,
|
||||
timeRangeApi: {
|
||||
timeRange$,
|
||||
setTimeRange,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -83,6 +83,7 @@ export function startDashboardSearchSessionIntegration(
|
|||
|
||||
if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) {
|
||||
this.searchSessionId = updatedSearchSessionId;
|
||||
this.searchSessionId$.next(updatedSearchSessionId);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
Container,
|
||||
DefaultEmbeddableApi,
|
||||
EmbeddableFactoryNotFoundError,
|
||||
embeddableInputToSubject,
|
||||
isExplicitInputWithAttributes,
|
||||
PanelNotFoundError,
|
||||
reactEmbeddableRegistryHasKey,
|
||||
|
@ -133,6 +134,9 @@ export class DashboardContainer
|
|||
public controlGroup?: ControlGroupContainer;
|
||||
|
||||
public searchSessionId?: string;
|
||||
public searchSessionId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
public reload$ = new Subject<void>();
|
||||
public timeslice$: BehaviorSubject<[number, number] | undefined>;
|
||||
public locator?: Pick<LocatorPublic<DashboardLocatorParams>, 'navigate' | 'getRedirectUrl'>;
|
||||
|
||||
// cleanup
|
||||
|
@ -201,6 +205,7 @@ export class DashboardContainer
|
|||
|
||||
this.creationOptions = creationOptions;
|
||||
this.searchSessionId = initialSessionId;
|
||||
this.searchSessionId$.next(initialSessionId);
|
||||
this.dashboardCreationStartTime = dashboardCreationStartTime;
|
||||
|
||||
// start diffing dashboard state
|
||||
|
@ -238,6 +243,10 @@ export class DashboardContainer
|
|||
})
|
||||
);
|
||||
this.startAuditingReactEmbeddableChildren();
|
||||
this.timeslice$ = embeddableInputToSubject<
|
||||
[number, number] | undefined,
|
||||
DashboardContainerInput
|
||||
>(this.publishingSubscription, this, 'timeslice');
|
||||
}
|
||||
|
||||
public getAppContext() {
|
||||
|
@ -523,6 +532,7 @@ export class DashboardContainer
|
|||
|
||||
public forceRefresh(refreshControlGroup: boolean = true) {
|
||||
this.dispatch.setLastReloadRequestTimeToNow({});
|
||||
this.reload$.next();
|
||||
if (refreshControlGroup) this.controlGroup?.reload();
|
||||
}
|
||||
|
||||
|
@ -591,6 +601,7 @@ export class DashboardContainer
|
|||
const { input: newInput, searchSessionId } = initializeResult;
|
||||
|
||||
this.searchSessionId = searchSessionId;
|
||||
this.searchSessionId$.next(searchSessionId);
|
||||
|
||||
batch(() => {
|
||||
this.dispatch.setLastSavedInput(
|
||||
|
|
|
@ -15,25 +15,23 @@ import {
|
|||
map,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
import { IEmbeddable } from '../..';
|
||||
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../..';
|
||||
import { Container } from '../../containers';
|
||||
import { ViewMode as LegacyViewMode } from '../../types';
|
||||
import {
|
||||
CommonLegacyEmbeddable,
|
||||
CommonLegacyInput,
|
||||
CommonLegacyOutput,
|
||||
} from './legacy_embeddable_to_api';
|
||||
import { CommonLegacyEmbeddable } from './legacy_embeddable_to_api';
|
||||
|
||||
export const embeddableInputToSubject = <
|
||||
T extends unknown = unknown,
|
||||
LegacyInput extends CommonLegacyInput = CommonLegacyInput
|
||||
ValueType extends unknown = unknown,
|
||||
LegacyInput extends EmbeddableInput = EmbeddableInput
|
||||
>(
|
||||
subscription: Subscription,
|
||||
embeddable: IEmbeddable<LegacyInput>,
|
||||
key: keyof LegacyInput,
|
||||
useExplicitInput = false
|
||||
) => {
|
||||
const subject = new BehaviorSubject<T | undefined>(embeddable.getExplicitInput()?.[key] as T);
|
||||
const subject = new BehaviorSubject<ValueType | undefined>(
|
||||
embeddable.getExplicitInput()?.[key] as ValueType
|
||||
);
|
||||
if (useExplicitInput && embeddable.parent) {
|
||||
subscription.add(
|
||||
embeddable.parent
|
||||
|
@ -47,33 +45,35 @@ export const embeddableInputToSubject = <
|
|||
return deepEqual(previousValue, currentValue);
|
||||
})
|
||||
)
|
||||
.subscribe(() => subject.next(embeddable.getExplicitInput()?.[key] as T))
|
||||
.subscribe(() => subject.next(embeddable.getExplicitInput()?.[key] as ValueType))
|
||||
);
|
||||
} else {
|
||||
subscription.add(
|
||||
embeddable
|
||||
.getInput$()
|
||||
.pipe(distinctUntilKeyChanged(key))
|
||||
.subscribe(() => subject.next(embeddable.getInput()?.[key] as T))
|
||||
.subscribe(() => subject.next(embeddable.getInput()?.[key] as ValueType))
|
||||
);
|
||||
}
|
||||
return subject;
|
||||
};
|
||||
|
||||
export const embeddableOutputToSubject = <
|
||||
T extends unknown = unknown,
|
||||
LegacyOutput extends CommonLegacyOutput = CommonLegacyOutput
|
||||
ValueType extends unknown = unknown,
|
||||
LegacyOutput extends EmbeddableOutput = EmbeddableOutput
|
||||
>(
|
||||
subscription: Subscription,
|
||||
embeddable: IEmbeddable<CommonLegacyInput, LegacyOutput>,
|
||||
embeddable: IEmbeddable<EmbeddableInput, LegacyOutput>,
|
||||
key: keyof LegacyOutput
|
||||
) => {
|
||||
const subject = new BehaviorSubject<T | undefined>(embeddable.getOutput()[key] as T);
|
||||
const subject = new BehaviorSubject<ValueType | undefined>(
|
||||
embeddable.getOutput()[key] as ValueType
|
||||
);
|
||||
subscription.add(
|
||||
embeddable
|
||||
.getOutput$()
|
||||
.pipe(distinctUntilKeyChanged(key))
|
||||
.subscribe(() => subject.next(embeddable.getOutput()[key] as T))
|
||||
.subscribe(() => subject.next(embeddable.getOutput()[key] as ValueType))
|
||||
);
|
||||
return subject;
|
||||
};
|
||||
|
|
|
@ -69,12 +69,18 @@ export const legacyEmbeddableToApi = (
|
|||
/**
|
||||
* Shortcuts for creating publishing subjects from the input and output subjects
|
||||
*/
|
||||
const inputKeyToSubject = <T extends unknown = unknown>(
|
||||
const inputKeyToSubject = <ValueType extends unknown = unknown>(
|
||||
key: keyof CommonLegacyInput,
|
||||
useExplicitInput?: boolean
|
||||
) => embeddableInputToSubject<T>(subscriptions, embeddable, key, useExplicitInput);
|
||||
const outputKeyToSubject = <T extends unknown = unknown>(key: keyof CommonLegacyOutput) =>
|
||||
embeddableOutputToSubject<T>(subscriptions, embeddable, key);
|
||||
) =>
|
||||
embeddableInputToSubject<ValueType, CommonLegacyInput>(
|
||||
subscriptions,
|
||||
embeddable,
|
||||
key,
|
||||
useExplicitInput
|
||||
);
|
||||
const outputKeyToSubject = <ValueType extends unknown = unknown>(key: keyof CommonLegacyOutput) =>
|
||||
embeddableOutputToSubject<ValueType, CommonLegacyOutput>(subscriptions, embeddable, key);
|
||||
|
||||
/**
|
||||
* Support editing of legacy embeddables
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue