Skip to content

Commit

Permalink
[Dashboard][Embeddable] Create Explicit Diffing System (#121241) (#12…
Browse files Browse the repository at this point in the history
…2668)

Co-authored-by: Anton Dosov <[email protected]>
Co-authored-by: nreese <[email protected]>
(cherry picked from commit 944ccf1)
  • Loading branch information
ThomThomson authored Jan 11, 2022
1 parent 28275fb commit 1ab9364
Show file tree
Hide file tree
Showing 17 changed files with 632 additions and 179 deletions.
6 changes: 2 additions & 4 deletions examples/embeddable_examples/public/book/book_embeddable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,11 @@ export class BookEmbeddable
};

readonly getInputAsValueType = async (): Promise<BookByValueInput> => {
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsValueType(input);
return this.attributeService.getInputAsValueType(this.getExplicitInput());
};

readonly getInputAsRefType = async (): Promise<BookByReferenceInput> => {
const input = this.attributeService.getExplicitInputFromEmbeddable(this);
return this.attributeService.getInputAsRefType(input, {
return this.attributeService.getInputAsRefType(this.getExplicitInput(), {
showSaveModal: true,
saveModalTitle: this.getTitle(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const createEditBookAction = (getStartServices: () => Promise<StartServic
const newInput = await attributeService.wrapAttributes(
attributes,
useRefType,
attributeService.getExplicitInputFromEmbeddable(embeddable)
embeddable.getExplicitInput()
);
if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) {
// Set the saved object ID to null so that update input will remove the existing savedObjectId...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

import _ from 'lodash';
import { History } from 'history';
import { debounceTime } from 'rxjs/operators';
import { debounceTime, switchMap } from 'rxjs/operators';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';

import { DashboardConstants } from '../..';
import { ViewMode } from '../../services/embeddable';
Expand Down Expand Up @@ -261,37 +261,47 @@ export const useDashboardAppState = ({
dashboardAppState.$onDashboardStateChange,
dashboardBuildContext.$checkForUnsavedChanges,
])
.pipe(debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE))
.subscribe((states) => {
const [lastSaved, current] = states;
const unsavedChanges = diffDashboardState(lastSaved, current);

const savedTimeChanged =
lastSaved.timeRestore &&
(!areTimeRangesEqual(
{
from: savedDashboard?.timeFrom,
to: savedDashboard?.timeTo,
},
timefilter.getTime()
) ||
!areRefreshIntervalsEqual(
savedDashboard?.refreshInterval,
timefilter.getRefreshInterval()
));

/**
* changes to the dashboard should only be considered 'unsaved changes' when
* editing the dashboard
*/
const hasUnsavedChanges =
current.viewMode === ViewMode.EDIT &&
(Object.keys(unsavedChanges).length > 0 || savedTimeChanged);
setDashboardAppState((s) => ({ ...s, hasUnsavedChanges }));

unsavedChanges.viewMode = current.viewMode; // always push view mode into session store.
dashboardSessionStorage.setState(savedDashboardId, unsavedChanges);
});
.pipe(
debounceTime(DashboardConstants.CHANGE_CHECK_DEBOUNCE),
switchMap((states) => {
return new Observable((observer) => {
const [lastSaved, current] = states;
diffDashboardState({
getEmbeddable: (id: string) => dashboardContainer.untilEmbeddableLoaded(id),
originalState: lastSaved,
newState: current,
}).then((unsavedChanges) => {
if (observer.closed) return;
const savedTimeChanged =
lastSaved.timeRestore &&
(!areTimeRangesEqual(
{
from: savedDashboard?.timeFrom,
to: savedDashboard?.timeTo,
},
timefilter.getTime()
) ||
!areRefreshIntervalsEqual(
savedDashboard?.refreshInterval,
timefilter.getRefreshInterval()
));

/**
* changes to the dashboard should only be considered 'unsaved changes' when
* editing the dashboard
*/
const hasUnsavedChanges =
current.viewMode === ViewMode.EDIT &&
(Object.keys(unsavedChanges).length > 0 || savedTimeChanged);
setDashboardAppState((s) => ({ ...s, hasUnsavedChanges }));

unsavedChanges.viewMode = current.viewMode; // always push view mode into session store.
dashboardSessionStorage.setState(savedDashboardId, unsavedChanges);
});
});
})
)
.subscribe();

/**
* initialize the last saved state, and build a callback which can be used to update
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* 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 { Filter } from '@kbn/es-query';

import { DashboardOptions, DashboardState } from '../../types';
import { diffDashboardState } from './diff_dashboard_state';
import { EmbeddableInput, IEmbeddable, ViewMode } from '../../services/embeddable';

const testFilter: Filter = {
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'hi' },
};

const getEmbeddable = (id: string) =>
Promise.resolve({
getExplicitInputIsEqual: (previousInput: EmbeddableInput) => true,
} as unknown as IEmbeddable);

const getDashboardState = (state?: Partial<DashboardState>): DashboardState => {
const defaultState: DashboardState = {
description: 'This is a dashboard which is very neat',
query: { query: '', language: 'kql' },
title: 'A very neat dashboard',
viewMode: ViewMode.VIEW,
fullScreenMode: false,
filters: [testFilter],
timeRestore: false,
tags: [],
options: {
hidePanelTitles: false,
useMargins: true,
syncColors: false,
},
panels: {
panel_1: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' },
panelRefName: 'panel_panel_1',
explicitInput: {
id: 'panel_1',
},
},
panel_2: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_2' },
panelRefName: 'panel_panel_2',
explicitInput: {
id: 'panel_1',
},
},
},
};
return { ...defaultState, ...state };
};

const getKeysFromDiff = async (partialState?: Partial<DashboardState>): Promise<string[]> =>
Object.keys(
await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(partialState),
getEmbeddable,
})
);

describe('Dashboard state diff function', () => {
it('finds no difference in equal states', async () => {
expect(await getKeysFromDiff()).toEqual([]);
});

it('diffs simple state keys correctly', async () => {
expect(
(
await getKeysFromDiff({
timeRestore: true,
title: 'what a cool new title',
description: 'what a cool new description',
query: { query: 'woah a query', language: 'kql' },
})
).sort()
).toEqual(['description', 'query', 'timeRestore', 'title']);
});

it('picks up differences in dashboard options', async () => {
expect(
await getKeysFromDiff({
options: {
hidePanelTitles: false,
useMargins: false,
syncColors: false,
},
})
).toEqual(['options']);
});

it('considers undefined and false to be equivalent in dashboard options', async () => {
expect(
await getKeysFromDiff({
options: {
useMargins: true,
syncColors: undefined,
} as unknown as DashboardOptions,
})
).toEqual([]);
});

it('calls getExplicitInputIsEqual on each panel', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id));
await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).toHaveBeenCalledTimes(2);
});

it('short circuits panels comparison when one panel returns false', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => {
if (id === 'panel_1') {
return Promise.resolve({
getExplicitInputIsEqual: (previousInput: EmbeddableInput) => false,
} as unknown as IEmbeddable);
}
getEmbeddable(id);
});

await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState(),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).toHaveBeenCalledTimes(1);
});

it('skips individual panel comparisons if panel ids are different', async () => {
const mockedGetEmbeddable = jest.fn().mockImplementation((id) => getEmbeddable(id));
const stateDiff = await diffDashboardState({
originalState: getDashboardState(),
newState: getDashboardState({
panels: {
panel_1: {
type: 'panel_type',
gridData: { w: 0, h: 0, x: 0, y: 0, i: 'panel_1' },
panelRefName: 'panel_panel_1',
explicitInput: {
id: 'panel_1',
},
},
// panel 2 has been deleted
},
}),
getEmbeddable: mockedGetEmbeddable,
});
expect(mockedGetEmbeddable).not.toHaveBeenCalled();
expect(Object.keys(stateDiff)).toEqual(['panels']);
});
});
Loading

0 comments on commit 1ab9364

Please sign in to comment.