Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cb6867f
working implementation of fetch only visible panels on a Dashboard.
ThomThomson Jun 25, 2025
34cf2a8
remove labs
ThomThomson Jun 25, 2025
69781b6
remove console logs
ThomThomson Jun 25, 2025
703f5b7
Merge remote-tracking branch 'upstream/main' into dashboard/fetchOnly…
ThomThomson Jul 8, 2025
431f32a
Merge remote-tracking branch 'upstream/main' into dashboard/fetchOnly…
ThomThomson Aug 26, 2025
83a3ec2
fix type errors
ThomThomson Aug 26, 2025
5342d78
add fetchOnlyVisible to out transform
ThomThomson Aug 27, 2025
a367ad2
[maps] update map embeddable to not load data when fetch is paused (#25)
nreese Aug 27, 2025
e774daf
fix maps types, delay discover session embeddable query
ThomThomson Aug 27, 2025
19861c8
update translation
ThomThomson Aug 27, 2025
b9a769a
[CI] Auto-commit changed files from 'node scripts/eslint_all_files --…
kibanamachine Aug 27, 2025
7dda166
Remove unused translations
ThomThomson Aug 28, 2025
f2c08f9
Merge remote-tracking branch 'refs/remotes/origin/dashboard/fetchOnly…
ThomThomson Aug 28, 2025
2889299
fix types, remove translations
ThomThomson Aug 28, 2025
2b9b104
fix jest tests
ThomThomson Aug 29, 2025
b9f72a5
Add jest tests
ThomThomson Sep 2, 2025
b819c6e
Merge remote-tracking branch 'upstream/main' into dashboard/fetchOnly…
ThomThomson Sep 2, 2025
d961f3b
Live expected panel counting code, update jest tests, better fallback…
ThomThomson Sep 4, 2025
5b62c98
[CI] Auto-commit changed files from 'node scripts/eslint_all_files --…
kibanamachine Sep 4, 2025
61d4803
fix jest tests
ThomThomson Sep 8, 2025
ff43e7e
fix error recovery
ThomThomson Sep 8, 2025
067b23f
remove instrumentation
ThomThomson Sep 8, 2025
65d764f
Merge remote-tracking branch 'upstream/main' into dashboard/fetchOnly…
ThomThomson Sep 8, 2025
76b5792
ignore pointer events
ThomThomson Sep 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,7 @@ export const TIMEPICKER_REFRESH_INTERVAL_DEFAULTS_ID = 'timepicker:refreshInterv
export const TIMEPICKER_TIME_DEFAULTS_ID = 'timepicker:timeDefaults';

// Presentation labs settings
export const LABS_CANVAS_BY_VALUE_EMBEDDABLE_ID = 'labs:canvas:byValueEmbeddable';
export const LABS_CANVAS_ENABLE_UI_ID = 'labs:canvas:enable_ui';
export const LABS_DASHBOARD_DEFER_BELOW_FOLD_ID = 'labs:dashboard:deferBelowFold';
export const LABS_DASHBOARDS_ENABLE_UI_ID = 'labs:dashboard:enable_ui';

// Accessibility settings
export const ACCESSIBILITY_DISABLE_ANIMATIONS_ID = 'accessibility:disableAnimations';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,25 @@ export {
getViewModeSubject,
type CanAccessViewMode,
} from './interfaces/can_access_view_mode';
export {
type PublishesPauseFetch,
apiPublishesPauseFetch,
} from './interfaces/fetch/publishes_pause_fetch';
export {
apiCanLockHoverActions,
type CanLockHoverActions,
} from './interfaces/can_lock_hover_actions';
export { fetch$, useFetchContext, type FetchContext } from './interfaces/fetch/fetch';
export { fetch$, useFetchContext } from './interfaces/fetch/fetch';
export type { FetchContext } from './interfaces/fetch/fetch_context';
export {
type FetchSetting,
type PublishesFetchSetting,
type PublishesIsVisible,
apiPublishesIsVisible,
apiPublishesFetchSetting,
onVisibilityChange,
initializeVisibility,
} from './interfaces/fetch/fetch_only_visible';
export {
initializeTimeRangeManager,
timeRangeComparators,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,58 @@ describe('onFetchContextChanged', () => {
});
});

describe('with isFetchPaused$', () => {
test('should skip emits while fetch is paused', async () => {
const isFetchPaused$ = new BehaviorSubject<boolean>(true);
const api = {
parentApi,
isFetchPaused$,
};
const subscription = fetch$(api).subscribe(onFetchMock);

parentApi.filters$.next([]);
parentApi.query$.next({ language: 'kquery', query: 'hello' });
parentApi.reload$.next();

await new Promise((resolve) => setTimeout(resolve, 0));
expect(onFetchMock).not.toHaveBeenCalled();

subscription.unsubscribe();
});

test('should emit most recent context when fetch becomes un-paused', async () => {
const isFetchPaused$ = new BehaviorSubject<boolean>(true);
const api = {
parentApi,
isFetchPaused$,
};
const subscription = fetch$(api).subscribe(onFetchMock);

parentApi.filters$.next([]);
parentApi.query$.next({ language: 'kquery', query: '' });
parentApi.reload$.next();

isFetchPaused$.next(false);

await new Promise((resolve) => setTimeout(resolve, 100));
expect(onFetchMock).toHaveBeenCalledTimes(1);
const fetchContext = onFetchMock.mock.calls[0][0];
expect(fetchContext).toEqual({
filters: [],
isReload: true,
query: {
language: 'kquery',
query: '',
},
searchSessionId: undefined,
timeRange: undefined,
timeslice: undefined,
});

subscription.unsubscribe();
});
});

describe('no searchSession$', () => {
test('should emit once on reload', async () => {
const subscription = fetch$({ parentApi }).pipe(skip(1)).subscribe(onFetchMock);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,49 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { Observable } from 'rxjs';
import { useEffect, useMemo } from 'react';
import {
BehaviorSubject,
Subject,
combineLatest,
combineLatestWith,
debounceTime,
delay,
distinctUntilChanged,
filter,
map,
merge,
of,
skip,
startWith,
Subject,
switchMap,
takeUntil,
tap,
type Observable,
} from 'rxjs';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { useMemo, useEffect } from 'react';
import type { PublishesTimeRange, PublishesUnifiedSearch } from './publishes_unified_search';
import { apiPublishesTimeRange, apiPublishesUnifiedSearch } from './publishes_unified_search';
import type { PublishesSearchSession } from './publishes_search_session';
import { apiPublishesSearchSession } from './publishes_search_session';
import type { HasParentApi } from '../has_parent_api';
import { apiHasParentApi } from '../has_parent_api';
import { apiPublishesReload } from './publishes_reload';
import { useStateFromPublishingSubject } from '../../publishing_subject';
import { apiHasParentApi, type HasParentApi } from '../has_parent_api';
import {
type FetchContext,
type ReloadTimeFetchContext,
isReloadTimeFetchContextEqual,
} from './fetch_context';
import { apiPublishesPauseFetch } from './publishes_pause_fetch';
import { apiPublishesReload } from './publishes_reload';
import { apiPublishesSearchSession, type PublishesSearchSession } from './publishes_search_session';
import {
apiPublishesTimeRange,
apiPublishesUnifiedSearch,
type PublishesTimeRange,
type PublishesUnifiedSearch,
} from './publishes_unified_search';

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) {
function getReloadTimeFetchContext(api: unknown, reloadTimestamp?: number): ReloadTimeFetchContext {
const typeApi = api as Partial<
PublishesTimeRange & HasParentApi<Partial<PublishesUnifiedSearch & PublishesSearchSession>>
>;
return {
isReload,
reloadTimestamp,
filters: typeApi?.parentApi?.filters$?.value,
query: typeApi?.parentApi?.query$?.value,
searchSessionId: typeApi?.parentApi?.searchSessionId$?.value,
Expand Down Expand Up @@ -121,36 +121,60 @@ export function fetch$(api: unknown): Observable<FetchContext> {
const batchedObservables = getBatchedObservables(api);
const immediateObservables = getImmediateObservables(api);

if (immediateObservables.length === 0) {
return merge(...batchedObservables).pipe(
startWith(getFetchContext(api, false)),
debounceTime(0),
map(() => getFetchContext(api, false))
);
}

const interrupt = new Subject<void>();
const batchedChanges$ = merge(...batchedObservables).pipe(
switchMap((value) =>
of(value).pipe(
delay(0),
takeUntil(interrupt),
map(() => getFetchContext(api, false))
const fetchContext$ = (() => {
if (immediateObservables.length === 0) {
return merge(...batchedObservables).pipe(
startWith(getReloadTimeFetchContext(api)),
debounceTime(0),
map(() => getReloadTimeFetchContext(api))
);
}
const interrupt = new Subject<void>();
const batchedChanges$ = merge(...batchedObservables).pipe(
switchMap((value) =>
of(value).pipe(
delay(0),
takeUntil(interrupt),
map(() => getReloadTimeFetchContext(api))
)
)
)
);
);

const immediateChange$ = merge(...immediateObservables).pipe(
tap(() => interrupt.next()),
map(() => getFetchContext(api, true))
const immediateChange$ = merge(...immediateObservables).pipe(
tap(() => {
interrupt.next();
}),
map(() => getReloadTimeFetchContext(api, Date.now()))
);
return merge(immediateChange$, batchedChanges$).pipe(startWith(getReloadTimeFetchContext(api)));
})();

const isFetchPaused$ = apiPublishesPauseFetch(api) ? api.isFetchPaused$ : of(false);

return fetchContext$.pipe(
combineLatestWith(isFetchPaused$),
filter(([, isFetchPaused]) => !isFetchPaused),
map(([fetchContext]) => fetchContext),
distinctUntilChanged((prevContext, nextContext) =>
isReloadTimeFetchContextEqual(prevContext, nextContext)
),
map((reloadTimeFetchContext) => ({
isReload: Boolean(reloadTimeFetchContext.reloadTimestamp),
filters: reloadTimeFetchContext.filters,
query: reloadTimeFetchContext.query,
timeRange: reloadTimeFetchContext.timeRange,
timeslice: reloadTimeFetchContext.timeslice,
searchSessionId: reloadTimeFetchContext.searchSessionId,
}))
);

return merge(immediateChange$, batchedChanges$).pipe(startWith(getFetchContext(api, false)));
}

export const useFetchContext = (api: unknown): FetchContext => {
const context$: BehaviorSubject<FetchContext> = useMemo(() => {
return new BehaviorSubject<FetchContext>(getFetchContext(api, false));
return new BehaviorSubject<FetchContext>({
...getReloadTimeFetchContext(api),
isReload: false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { COMPARE_ALL_OPTIONS, onlyDisabledFiltersChanged } from '@kbn/es-query';
import fastIsEqual from 'fast-deep-equal';

export interface FetchContext {
isReload: boolean;
filters: Filter[] | undefined;
query: Query | AggregateQuery | undefined;
searchSessionId: string | undefined;
timeRange: TimeRange | undefined;
timeslice: [number, number] | undefined;
}

export interface ReloadTimeFetchContext extends Omit<FetchContext, 'isReload'> {
reloadTimestamp?: number;
}

export function isReloadTimeFetchContextEqual(
currentContext: ReloadTimeFetchContext,
lastContext: ReloadTimeFetchContext
): boolean {
if (currentContext.searchSessionId !== lastContext.searchSessionId) return false;

return (
isReloadTimestampEqualForFetch(currentContext.reloadTimestamp, lastContext.reloadTimestamp) &&
areFiltersEqualForFetch(currentContext.filters, lastContext.filters) &&
isQueryEqualForFetch(currentContext.query, lastContext.query) &&
isTimeRangeEqualForFetch(currentContext.timeRange, lastContext.timeRange) &&
isTimeSliceEqualForFetch(currentContext.timeslice, lastContext.timeslice)
);
}

export const areFiltersEqualForFetch = (currentFilters?: Filter[], lastFilters?: Filter[]) => {
return onlyDisabledFiltersChanged(currentFilters, lastFilters, {
...COMPARE_ALL_OPTIONS,
// do not compare $state to avoid refreshing when filter is pinned/unpinned (which does not impact results)
state: false,
});
};

export const isReloadTimestampEqualForFetch = (
currentReloadTimestamp?: number,
lastReloadTimestamp?: number
) => {
if (!currentReloadTimestamp) return true; // if current reload timestamp is not set, this is not a force refresh.
return currentReloadTimestamp === lastReloadTimestamp;
};

export const isQueryEqualForFetch = (
currentQuery: Query | AggregateQuery | undefined,
lastQuery: Query | AggregateQuery | undefined
) => fastIsEqual(currentQuery, lastQuery);

export const isTimeRangeEqualForFetch = (
currentTimeRange: TimeRange | undefined,
lastTimeRange: TimeRange | undefined
) => fastIsEqual(currentTimeRange, lastTimeRange);

export const isTimeSliceEqualForFetch = (
currentTimeslice: [number, number] | undefined,
lastTimeslice: [number, number] | undefined
) => fastIsEqual(currentTimeslice, lastTimeslice);
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { BehaviorSubject, skip, type Observable } from 'rxjs';
import { combineLatestWith, map } from 'rxjs';
import type { PublishesPauseFetch } from '../..';

export type FetchSetting = 'onlyVisible' | 'always';

/**
* Parent APIs can publish a fetch setting that determines when child components should fetch data.
*/
export interface PublishesFetchSetting {
fetchSetting$: Observable<FetchSetting>;
}

export const apiPublishesFetchSetting = (
unknownApi?: unknown
): unknownApi is PublishesFetchSetting => {
return Boolean(unknownApi && (unknownApi as PublishesFetchSetting)?.fetchSetting$ !== undefined);
};

export interface PublishesIsVisible {
isVisible$: BehaviorSubject<boolean>;
}

export const apiPublishesIsVisible = (unknownApi?: unknown): unknownApi is PublishesIsVisible => {
return Boolean(unknownApi && (unknownApi as PublishesIsVisible)?.isVisible$ !== undefined);
};

export const onVisibilityChange = (api: unknown, isVisible: boolean) => {
if (apiPublishesIsVisible(api)) api.isVisible$.next(isVisible);
};

export const initializeVisibility = (
parentApi: unknown
): (PublishesPauseFetch & PublishesIsVisible) | {} => {
if (!apiPublishesFetchSetting(parentApi)) return {};

const isVisible$ = new BehaviorSubject<boolean>(false);
const isFetchPaused$ = parentApi.fetchSetting$.pipe(
combineLatestWith(isVisible$.pipe(skip(1))),
map(([parentFetchSetting, isVisible]) => {
if (parentFetchSetting === 'onlyVisible') return !isVisible;
return false; // If the fetch setting is 'always', we do not pause the fetch
})
);
return {
isVisible$,
isFetchPaused$,
};
};
Loading