[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:
Nathan Reese 2024-04-04 20:57:03 -06:00 committed by GitHub
parent 8547ab2a00
commit e23335ba8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 630 additions and 291 deletions

View file

@ -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">

View file

@ -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,

View file

@ -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,
},
};
};

View file

@ -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();
});
});
});
});

View file

@ -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());
};
}

View file

@ -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);
};

View file

@ -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
);
};

View file

@ -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 & {

View file

@ -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();
});
});
});
});

View file

@ -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,
},
};
};

View file

@ -83,6 +83,7 @@ export function startDashboardSearchSessionIntegration(
if (updatedSearchSessionId && updatedSearchSessionId !== currentSearchSessionId) {
this.searchSessionId = updatedSearchSessionId;
this.searchSessionId$.next(updatedSearchSessionId);
}
});

View file

@ -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(

View file

@ -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;
};

View file

@ -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