[Dashboard][Controls] Hierarchical Chaining (#126649)

* Hierarchical Chaining Implementation
This commit is contained in:
Devon Thomson 2022-03-14 12:23:17 -04:00 committed by GitHub
parent 93704a7a0b
commit 2bf66ffe91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 752 additions and 203 deletions

View file

@ -15,6 +15,7 @@ import {
ContainerInput,
ContainerOutput,
EmbeddableStart,
EmbeddableChildPanel,
} from '../../../../src/plugins/embeddable/public';
interface Props {
@ -31,7 +32,6 @@ function renderList(
) {
let number = 0;
const list = Object.values(panels).map((panel) => {
const child = embeddable.getChild(panel.explicitInput.id);
number++;
return (
<EuiPanel key={number.toString()}>
@ -42,7 +42,11 @@ function renderList(
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<embeddableServices.EmbeddablePanel embeddable={child} />
<EmbeddableChildPanel
PanelComponent={embeddableServices.EmbeddablePanel}
embeddableId={panel.explicitInput.id}
container={embeddable}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -0,0 +1,32 @@
/*
* 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 { ControlGroupInput, ControlsPanels } from '..';
export const makeControlOrdersZeroBased = (input: ControlGroupInput) => {
if (
input.panels &&
typeof input.panels === 'object' &&
Object.keys(input.panels).length > 0 &&
!Object.values(input.panels).find((panel) => (panel.order ?? 0) === 0)
) {
// 0th element could not be found. Reorder all panels from 0;
const newPanels = Object.values(input.panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((panel, index) => {
panel.order = index;
return panel;
})
.reduce((acc, currentPanel) => {
acc[currentPanel.explicitInput.id] = currentPanel;
return acc;
}, {} as ControlsPanels);
input.panels = newPanels;
}
return input;
};

View file

@ -13,6 +13,8 @@ import {
} from '../../../embeddable/common/types';
import { ControlGroupInput, ControlPanelState } from './types';
import { SavedObjectReference } from '../../../../core/types';
import { MigrateFunctionsObject } from '../../../kibana_utils/common';
import { makeControlOrdersZeroBased } from './control_group_migrations';
type ControlGroupInputWithType = Partial<ControlGroupInput> & { type: string };
@ -83,3 +85,11 @@ export const createControlGroupExtract = (
return { state: workingState as EmbeddableStateWithType, references };
};
};
export const migrations: MigrateFunctionsObject = {
'8.2.0': (state) => {
const controlInput = state as unknown as ControlGroupInput;
// for hierarchical chaining it is required that all control orders start at 0.
return makeControlOrdersZeroBased(controlInput);
},
};

View file

@ -11,14 +11,23 @@ import { uniqBy } from 'lodash';
import ReactDOM from 'react-dom';
import deepEqual from 'fast-deep-equal';
import { Filter, uniqFilters } from '@kbn/es-query';
import { EMPTY, merge, pipe, Subscription } from 'rxjs';
import { distinctUntilChanged, debounceTime, catchError, switchMap, map } from 'rxjs/operators';
import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs';
import { EuiContextMenuPanel, EuiHorizontalRule } from '@elastic/eui';
import {
distinctUntilChanged,
debounceTime,
catchError,
switchMap,
map,
skip,
mapTo,
} from 'rxjs/operators';
import {
ControlGroupInput,
ControlGroupOutput,
ControlPanelState,
ControlsPanels,
CONTROL_GROUP_TYPE,
} from '../types';
import {
@ -29,44 +38,48 @@ import {
} from '../../../../presentation_util/public';
import { pluginServices } from '../../services';
import { DataView } from '../../../../data_views/public';
import { ControlGroupStrings } from '../control_group_strings';
import { EditControlGroup } from '../editor/edit_control_group';
import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { Container, EmbeddableFactory } from '../../../../embeddable/public';
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { EditControlGroup } from '../editor/edit_control_group';
import { ControlGroupStrings } from '../control_group_strings';
import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public';
const ControlGroupReduxWrapper = withSuspense<
ReduxEmbeddableWrapperPropsWithChildren<ControlGroupInput>
>(LazyReduxEmbeddableWrapper);
interface ChildEmbeddableOrderCache {
IdsToOrder: { [key: string]: number };
idsInOrder: string[];
lastChildId: string;
}
const controlOrdersAreEqual = (panelsA: ControlsPanels, panelsB: ControlsPanels) => {
const ordersA = Object.values(panelsA).map((panel) => ({
id: panel.explicitInput.id,
order: panel.order,
}));
const ordersB = Object.values(panelsB).map((panel) => ({
id: panel.explicitInput.id,
order: panel.order,
}));
return deepEqual(ordersA, ordersB);
};
export class ControlGroupContainer extends Container<
ControlInput,
ControlGroupInput,
ControlGroupOutput
> {
public readonly type = CONTROL_GROUP_TYPE;
private subscriptions: Subscription = new Subscription();
private domNode?: HTMLElement;
public untilReady = () => {
const panelsLoading = () =>
Object.values(this.getOutput().embeddableLoaded).some((loaded) => !loaded);
if (panelsLoading()) {
return new Promise<void>((resolve, reject) => {
const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => {
if (this.destroyed) reject();
if (!panelsLoading()) {
subscription.unsubscribe();
resolve();
}
});
});
}
return Promise.resolve();
};
private childOrderCache: ChildEmbeddableOrderCache;
private recalculateFilters$: Subject<null>;
/**
* Returns a button that allows controls to be created externally using the embeddable
@ -141,51 +154,150 @@ export class ControlGroupContainer extends Container<
initialInput,
{ embeddableLoaded: {} },
pluginServices.getServices().controls.getControlFactory,
parent
parent,
{
childIdInitializeOrder: Object.values(initialInput.panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map((panel) => panel.explicitInput.id),
initializeSequentially: true,
}
);
// when all children are ready start recalculating filters when any child's output changes
this.recalculateFilters$ = new Subject();
// set up order cache so that it is aligned on input changes.
this.childOrderCache = this.getEmbeddableOrderCache();
// when all children are ready setup subscriptions
this.untilReady().then(() => {
this.recalculateOutput();
const anyChildChangePipe = pipe(
map(() => this.getChildIds()),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) =>
merge(
...newChildIds.map((childId) =>
this.getChild(childId)
.getOutput$()
// Embeddables often throw errors into their output streams.
.pipe(catchError(() => EMPTY))
)
)
)
);
this.subscriptions.add(
merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe))
.pipe(debounceTime(10))
.subscribe(this.recalculateOutput)
);
this.recalculateDataViews();
this.recalculateFilters();
this.setupSubscriptions();
});
}
private setupSubscriptions = () => {
/**
* refresh control order cache and make all panels refreshInputFromParent whenever panel orders change
*/
this.subscriptions.add(
this.getInput$()
.pipe(
skip(1),
distinctUntilChanged((a, b) => controlOrdersAreEqual(a.panels, b.panels))
)
.subscribe(() => {
this.recalculateDataViews();
this.recalculateFilters();
this.childOrderCache = this.getEmbeddableOrderCache();
this.childOrderCache.idsInOrder.forEach((id) =>
this.getChild(id)?.refreshInputFromParent()
);
})
);
/**
* Create a pipe that outputs the child's ID, any time any child's output changes.
*/
const anyChildChangePipe = pipe(
map(() => this.getChildIds()),
distinctUntilChanged(deepEqual),
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) =>
merge(
...newChildIds.map((childId) =>
this.getChild(childId)
.getOutput$()
.pipe(
// Embeddables often throw errors into their output streams.
catchError(() => EMPTY),
mapTo(childId)
)
)
)
)
);
/**
* run OnChildOutputChanged when any child's output has changed
*/
this.subscriptions.add(
this.getOutput$()
.pipe(anyChildChangePipe)
.subscribe((childOutputChangedId) => {
this.recalculateDataViews();
if (childOutputChangedId === this.childOrderCache.lastChildId) {
// the last control's output has updated, recalculate filters
this.recalculateFilters$.next();
return;
}
// when output changes on a child which isn't the last - make the next embeddable updateInputFromParent
const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1;
if (nextOrder >= Object.keys(this.children).length) return;
setTimeout(
() =>
this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(),
1 // run on next tick
);
})
);
/**
* debounce output recalculation
*/
this.subscriptions.add(
this.recalculateFilters$.pipe(debounceTime(10)).subscribe(() => this.recalculateFilters())
);
};
private getPrecedingFilters = (id: string) => {
let filters: Filter[] = [];
const order = this.childOrderCache.IdsToOrder?.[id];
if (!order || order === 0) return filters;
for (let i = 0; i < order; i++) {
const embeddable = this.getChild<ControlEmbeddable>(this.childOrderCache.idsInOrder[i]);
if (!embeddable || isErrorEmbeddable(embeddable)) return filters;
filters = [...filters, ...(embeddable.getOutput().filters ?? [])];
}
return filters;
};
private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => {
const panels = this.getInput().panels;
const IdsToOrder: { [key: string]: number } = {};
const idsInOrder: string[] = [];
Object.values(panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.forEach((panel) => {
IdsToOrder[panel.explicitInput.id] = panel.order;
idsInOrder.push(panel.explicitInput.id);
});
const lastChildId = idsInOrder[idsInOrder.length - 1];
return { IdsToOrder, idsInOrder, lastChildId };
};
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
};
private recalculateOutput = () => {
private recalculateFilters = () => {
const allFilters: Filter[] = [];
const allDataViews: DataView[] = [];
Object.values(this.children).map((child) => {
const childOutput = child.getOutput() as ControlOutput;
allFilters.push(...(childOutput?.filters ?? []));
});
this.updateOutput({ filters: uniqFilters(allFilters) });
};
private recalculateDataViews = () => {
const allDataViews: DataView[] = [];
Object.values(this.children).map((child) => {
const childOutput = child.getOutput() as ControlOutput;
allDataViews.push(...(childOutput.dataViews ?? []));
});
this.updateOutput({ filters: uniqFilters(allFilters), dataViews: uniqBy(allDataViews, 'id') });
this.updateOutput({ dataViews: uniqBy(allDataViews, 'id') });
};
protected createNewPanelState<TEmbeddableInput extends ControlInput = ControlInput>(
@ -193,12 +305,16 @@ export class ControlGroupContainer extends Container<
partial: Partial<TEmbeddableInput> = {}
): ControlPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
const highestOrder = Object.values(this.getInput().panels).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0);
let nextOrder = 0;
if (Object.keys(this.getInput().panels).length > 0) {
nextOrder =
Object.values(this.getInput().panels).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0) + 1;
}
return {
order: highestOrder + 1,
order: nextOrder,
width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH,
...panelState,
} as ControlPanelState<TEmbeddableInput>;
@ -206,19 +322,38 @@ export class ControlGroupContainer extends Container<
protected getInheritedInput(id: string): ControlInput {
const { filters, query, ignoreParentSettings, timeRange } = this.getInput();
const precedingFilters = this.getPrecedingFilters(id);
const allFilters = [
...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []),
...precedingFilters,
];
return {
filters: ignoreParentSettings?.ignoreFilters ? undefined : filters,
filters: allFilters,
query: ignoreParentSettings?.ignoreQuery ? undefined : query,
timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange,
id,
};
}
public destroy() {
super.destroy();
this.subscriptions.unsubscribe();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
public untilReady = () => {
const panelsLoading = () =>
Object.keys(this.getInput().panels).some(
(panelId) => !this.getOutput().embeddableLoaded[panelId]
);
if (panelsLoading()) {
return new Promise<void>((resolve, reject) => {
const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => {
if (this.destroyed) reject();
if (!panelsLoading()) {
subscription.unsubscribe();
resolve();
}
});
});
}
return Promise.resolve();
};
public render(dom: HTMLElement) {
if (this.domNode) {
@ -235,4 +370,10 @@ export class ControlGroupContainer extends Container<
dom
);
}
public destroy() {
super.destroy();
this.subscriptions.unsubscribe();
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
}
}

View file

@ -138,45 +138,36 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
.subscribe(this.runOptionsListQuery)
);
// build filters when selectedOptions or invalidSelections change
this.subscriptions.add(
this.componentStateSubject$
.pipe(
debounceTime(100),
distinctUntilChanged((a, b) => isEqual(a.validSelections, b.validSelections)),
skip(1) // skip the first input update because initial filters will be built by initialize.
)
.subscribe(() => this.buildFilter())
);
/**
* when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections.
* when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections, and publish filter
**/
this.subscriptions.add(
this.getInput$()
.pipe(distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions)))
.subscribe(({ selectedOptions: newSelectedOptions }) => {
.subscribe(async ({ selectedOptions: newSelectedOptions }) => {
if (!newSelectedOptions || isEmpty(newSelectedOptions)) {
this.updateComponentState({
validSelections: [],
invalidSelections: [],
});
return;
}
const { invalidSelections } = this.componentStateSubject$.getValue();
const newValidSelections: string[] = [];
const newInvalidSelections: string[] = [];
for (const selectedOption of newSelectedOptions) {
if (invalidSelections?.includes(selectedOption)) {
newInvalidSelections.push(selectedOption);
continue;
} else {
const { invalidSelections } = this.componentStateSubject$.getValue();
const newValidSelections: string[] = [];
const newInvalidSelections: string[] = [];
for (const selectedOption of newSelectedOptions) {
if (invalidSelections?.includes(selectedOption)) {
newInvalidSelections.push(selectedOption);
continue;
}
newValidSelections.push(selectedOption);
}
newValidSelections.push(selectedOption);
this.updateComponentState({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
}
this.updateComponentState({
validSelections: newValidSelections,
invalidSelections: newInvalidSelections,
});
const newFilters = await this.buildFilter();
this.updateOutput({ filters: newFilters });
})
);
};
@ -216,8 +207,9 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
}
private runOptionsListQuery = async () => {
this.updateComponentState({ loading: true });
const { dataView, field } = await this.getCurrentDataViewAndField();
this.updateComponentState({ loading: true });
this.updateOutput({ loading: true, dataViews: [dataView] });
const { ignoreParentSettings, filters, query, selectedOptions, timeRange } = this.getInput();
if (this.abortController) this.abortController.abort();
@ -244,30 +236,32 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
totalCardinality,
loading: false,
});
return;
} else {
const valid: string[] = [];
const invalid: string[] = [];
for (const selectedOption of selectedOptions) {
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
loading: false,
});
}
const valid: string[] = [];
const invalid: string[] = [];
for (const selectedOption of selectedOptions) {
if (invalidSelections?.includes(selectedOption)) invalid.push(selectedOption);
else valid.push(selectedOption);
}
this.updateComponentState({
availableOptions: suggestions,
invalidSelections: invalid,
validSelections: valid,
totalCardinality,
loading: false,
});
// publish filter
const newFilters = await this.buildFilter();
this.updateOutput({ loading: false, filters: newFilters });
};
private buildFilter = async () => {
const { validSelections } = this.componentState;
if (!validSelections || isEmpty(validSelections)) {
this.updateOutput({ filters: [] });
return;
return [];
}
const { dataView, field } = await this.getCurrentDataViewAndField();
@ -279,7 +273,7 @@ export class OptionsListEmbeddable extends Embeddable<OptionsListEmbeddableInput
}
newFilter.meta.key = field?.name;
this.updateOutput({ filters: [newFilter] });
return [newFilter];
};
reload = () => {

View file

@ -12,6 +12,7 @@ import { CONTROL_GROUP_TYPE } from '../../common';
import {
createControlGroupExtract,
createControlGroupInject,
migrations,
} from '../../common/control_group/control_group_persistable_state';
export const controlGroupContainerPersistableStateServiceFactory = (
@ -21,5 +22,6 @@ export const controlGroupContainerPersistableStateServiceFactory = (
id: CONTROL_GROUP_TYPE,
extract: createControlGroupExtract(persistableStateService),
inject: createControlGroupInject(persistableStateService),
migrations,
};
};

View file

@ -0,0 +1,63 @@
/*
* 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 { SerializableRecord } from '@kbn/utility-types';
import { ControlGroupInput } from '../../../controls/common';
import { ControlStyle } from '../../../controls/common/types';
import { RawControlGroupAttributes } from '../types';
export const controlGroupInputToRawAttributes = (
controlGroupInput: Omit<ControlGroupInput, 'id'>
): Omit<RawControlGroupAttributes, 'id'> => {
return {
controlStyle: controlGroupInput.controlStyle,
panelsJSON: JSON.stringify(controlGroupInput.panels),
};
};
export const getDefaultDashboardControlGroupInput = () => ({
controlStyle: 'oneLine' as ControlGroupInput['controlStyle'],
panels: {},
});
export const rawAttributesToControlGroupInput = (
rawControlGroupAttributes: Omit<RawControlGroupAttributes, 'id'>
): Omit<ControlGroupInput, 'id'> | undefined => {
const defaultControlGroupInput = getDefaultDashboardControlGroupInput();
return {
controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle,
panels:
rawControlGroupAttributes?.panelsJSON &&
typeof rawControlGroupAttributes?.panelsJSON === 'string'
? JSON.parse(rawControlGroupAttributes?.panelsJSON)
: defaultControlGroupInput.panels,
};
};
export const rawAttributesToSerializable = (
rawControlGroupAttributes: Omit<RawControlGroupAttributes, 'id'>
): SerializableRecord => {
const defaultControlGroupInput = getDefaultDashboardControlGroupInput();
return {
controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle,
panels:
rawControlGroupAttributes?.panelsJSON &&
typeof rawControlGroupAttributes?.panelsJSON === 'string'
? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord)
: defaultControlGroupInput.panels,
};
};
export const serializableToRawAttributes = (
controlGroupInput: SerializableRecord
): Omit<RawControlGroupAttributes, 'id'> => {
return {
controlStyle: controlGroupInput.controlStyle as ControlStyle,
panelsJSON: JSON.stringify(controlGroupInput.panels),
};
};

View file

@ -28,3 +28,11 @@ export { migratePanelsTo730 } from './migrate_to_730_panels';
export const UI_SETTINGS = {
ENABLE_LABS_UI: 'labs:dashboard:enable_ui',
};
export {
controlGroupInputToRawAttributes,
getDefaultDashboardControlGroupInput,
rawAttributesToControlGroupInput,
rawAttributesToSerializable,
serializableToRawAttributes,
} from './embeddable/dashboard_control_group';

View file

@ -134,26 +134,27 @@ test('DashboardContainer.replacePanel', async (done) => {
const container = new DashboardContainer(initialInput, options);
let counter = 0;
const subscriptionHandler = jest.fn(({ panels }) => {
counter++;
expect(panels[ID]).toBeDefined();
// It should be called exactly 2 times and exit the second time
switch (counter) {
case 1:
return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE);
const subscription = container.getInput$().subscribe(
jest.fn(({ panels }) => {
counter++;
expect(panels[ID]).toBeDefined();
// It should be called exactly 2 times and exit the second time
switch (counter) {
case 1:
return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE);
case 2: {
expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE);
subscription.unsubscribe();
done();
case 2: {
expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE);
subscription.unsubscribe();
done();
return;
}
default:
throw Error('Called too many times!');
}
default:
throw Error('Called too many times!');
}
});
const subscription = container.getInput$().subscribe(subscriptionHandler);
})
);
// replace the panel now
container.replacePanel(container.getInput().panels[ID], {
@ -162,7 +163,7 @@ test('DashboardContainer.replacePanel', async (done) => {
});
});
test('Container view mode change propagates to existing children', async () => {
test('Container view mode change propagates to existing children', async (done) => {
const initialInput = getSampleDashboardInput({
panels: {
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
@ -172,12 +173,12 @@ test('Container view mode change propagates to existing children', async () => {
},
});
const container = new DashboardContainer(initialInput, options);
await nextTick();
const embeddable = await container.getChild('123');
const embeddable = await container.untilEmbeddableLoaded('123');
expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW);
container.updateInput({ viewMode: ViewMode.EDIT });
expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT);
done();
});
test('Container view mode change propagates to new children', async () => {

View file

@ -29,7 +29,8 @@ import {
ControlGroupOutput,
CONTROL_GROUP_TYPE,
} from '../../../../controls/public';
import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants';
import { getDefaultDashboardControlGroupInput } from '../../../common/embeddable/dashboard_control_group';
export type DashboardContainerFactory = EmbeddableFactory<
DashboardContainerInput,

View file

@ -13,9 +13,13 @@ import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators';
import { DashboardContainer } from '..';
import { DashboardState } from '../../types';
import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants';
import { DashboardContainerInput, DashboardSavedObject } from '../..';
import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public';
import {
controlGroupInputToRawAttributes,
getDefaultDashboardControlGroupInput,
rawAttributesToControlGroupInput,
} from '../../../common';
// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard.
export interface DashboardControlGroupInput {
@ -176,10 +180,9 @@ export const serializeControlGroupToDashboardSavedObject = (
return;
}
if (dashboardState.controlGroupInput) {
dashboardSavedObject.controlGroupInput = {
controlStyle: dashboardState.controlGroupInput.controlStyle,
panelsJSON: JSON.stringify(dashboardState.controlGroupInput.panels),
};
dashboardSavedObject.controlGroupInput = controlGroupInputToRawAttributes(
dashboardState.controlGroupInput
);
}
};
@ -187,15 +190,7 @@ export const deserializeControlGroupFromDashboardSavedObject = (
dashboardSavedObject: DashboardSavedObject
): Omit<ControlGroupInput, 'id'> | undefined => {
if (!dashboardSavedObject.controlGroupInput) return;
const defaultControlGroupInput = getDefaultDashboardControlGroupInput();
return {
controlStyle:
dashboardSavedObject.controlGroupInput?.controlStyle ?? defaultControlGroupInput.controlStyle,
panels: dashboardSavedObject.controlGroupInput?.panelsJSON
? JSON.parse(dashboardSavedObject.controlGroupInput?.panelsJSON)
: {},
};
return rawAttributesToControlGroupInput(dashboardSavedObject.controlGroupInput);
};
export const combineDashboardFiltersWithControlGroupFilters = (

View file

@ -6,8 +6,6 @@
* Side Public License, v 1.
*/
import type { ControlStyle } from '../../controls/public';
export const DASHBOARD_STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';
@ -25,11 +23,6 @@ export const DashboardConstants = {
CHANGE_APPLY_DEBOUNCE: 50,
};
export const getDefaultDashboardControlGroupInput = () => ({
controlStyle: 'oneLine' as ControlStyle,
panels: {},
});
export function createDashboardEditUrl(id?: string, editMode?: boolean) {
if (!id) {
return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`;

View file

@ -17,8 +17,7 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar
import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types';
import { DashboardOptions } from '../types';
import { ControlStyle } from '../../../controls/public';
import { RawControlGroupAttributes } from '../application';
export interface DashboardSavedObject extends SavedObject {
id?: string;
@ -39,7 +38,7 @@ export interface DashboardSavedObject extends SavedObject {
outcome?: string;
aliasId?: string;
controlGroupInput?: { controlStyle?: ControlStyle; panelsJSON?: string };
controlGroupInput?: Omit<RawControlGroupAttributes, 'id'>;
}
const defaults = {

View file

@ -18,7 +18,12 @@ import { migrations730 } from './migrations_730';
import { SavedDashboardPanel } from '../../common/types';
import { EmbeddableSetup } from '../../../embeddable/server';
import { migrateMatchAllQuery } from './migrate_match_all_query';
import { DashboardDoc700To720, DashboardDoc730ToLatest } from '../../common';
import {
serializableToRawAttributes,
DashboardDoc700To720,
DashboardDoc730ToLatest,
rawAttributesToSerializable,
} from '../../common';
import { injectReferences, extractReferences } from '../../common/saved_dashboard_references';
import {
convertPanelStateToSavedDashboardPanel,
@ -32,6 +37,7 @@ import {
MigrateFunctionsObject,
} from '../../../kibana_utils/common';
import { replaceIndexPatternReference } from './replace_index_pattern_reference';
import { CONTROL_GROUP_TYPE } from '../../../controls/common';
function migrateIndexPattern(doc: DashboardDoc700To720) {
const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON');
@ -163,12 +169,23 @@ const migrateByValuePanels =
(migrate: MigrateFunction, version: string): SavedObjectMigrationFn =>
(doc: any) => {
const { attributes } = doc;
if (attributes?.controlGroupInput) {
const controlGroupInput = rawAttributesToSerializable(attributes.controlGroupInput);
const migratedControlGroupInput = migrate({
...controlGroupInput,
type: CONTROL_GROUP_TYPE,
});
attributes.controlGroupInput = serializableToRawAttributes(migratedControlGroupInput);
}
// Skip if panelsJSON is missing otherwise this will cause saved object import to fail when
// importing objects without panelsJSON. At development time of this, there is no guarantee each saved
// object has panelsJSON in all previous versions of kibana.
if (typeof attributes?.panelsJSON !== 'string') {
return doc;
}
const panels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[];
// Same here, prevent failing saved object import if ever panels aren't an array.
if (!Array.isArray(panels)) {

View file

@ -9,7 +9,8 @@
import uuid from 'uuid';
import { isEqual, xor } from 'lodash';
import { merge, Subscription } from 'rxjs';
import { startWith, pairwise } from 'rxjs/operators';
import { pairwise, take, delay } from 'rxjs/operators';
import {
Embeddable,
EmbeddableInput,
@ -19,7 +20,13 @@ import {
IEmbeddable,
isErrorEmbeddable,
} from '../embeddables';
import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container';
import {
IContainer,
ContainerInput,
ContainerOutput,
PanelState,
EmbeddableContainerSettings,
} from './i_container';
import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors';
import { EmbeddableStart } from '../../plugin';
import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable';
@ -39,19 +46,29 @@ export abstract class Container<
[key: string]: IEmbeddable<any, any> | ErrorEmbeddable;
} = {};
private subscription: Subscription;
private subscription: Subscription | undefined;
constructor(
input: TContainerInput,
output: TContainerOutput,
protected readonly getFactory: EmbeddableStart['getEmbeddableFactory'],
parent?: Container
parent?: IContainer,
settings?: EmbeddableContainerSettings
) {
super(input, output, parent);
this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834
// initialize all children on the first input change. Delayed so it is run after the constructor is finished.
this.getInput$()
.pipe(delay(0), take(1))
.subscribe(() => {
this.initializeChildEmbeddables(input, settings);
});
// on all subsequent input changes, diff and update children on changes.
this.subscription = this.getInput$()
// At each update event, get both the previous and current state
.pipe(startWith(input), pairwise())
// At each update event, get both the previous and current state.
.pipe(pairwise())
.subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => {
this.maybeUpdateChildren(currentPanels, prevPanels);
});
@ -166,7 +183,7 @@ export abstract class Container<
public destroy() {
super.destroy();
Object.values(this.children).forEach((child) => child.destroy());
this.subscription.unsubscribe();
this.subscription?.unsubscribe();
}
public async untilEmbeddableLoaded<TEmbeddable extends IEmbeddable>(
@ -264,6 +281,33 @@ export abstract class Container<
*/
protected abstract getInheritedInput(id: string): TChildInput;
private async initializeChildEmbeddables(
initialInput: TContainerInput,
initializeSettings?: EmbeddableContainerSettings
) {
let initializeOrder = Object.keys(initialInput.panels);
if (initializeSettings?.childIdInitializeOrder) {
const initializeOrderSet = new Set<string>();
for (const id of [...initializeSettings.childIdInitializeOrder, ...initializeOrder]) {
if (!initializeOrderSet.has(id) && Boolean(this.getInput().panels[id])) {
initializeOrderSet.add(id);
}
}
initializeOrder = Array.from(initializeOrderSet);
}
for (const id of initializeOrder) {
if (initializeSettings?.initializeSequentially) {
const embeddable = await this.onPanelAdded(initialInput.panels[id]);
if (embeddable && !isErrorEmbeddable(embeddable)) {
await this.untilEmbeddableLoaded(id);
}
} else {
this.onPanelAdded(initialInput.panels[id]);
}
}
}
private async createAndSaveEmbeddable<
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
TEmbeddable extends IEmbeddable<TEmbeddableInput> = IEmbeddable<TEmbeddableInput>

View file

@ -7,7 +7,6 @@
*/
import React from 'react';
import { nextTick } from '@kbn/test-jest-helpers';
import { EmbeddableChildPanel } from './embeddable_child_panel';
import { CONTACT_CARD_EMBEDDABLE } from '../test_samples/embeddables/contact_card/contact_card_embeddable_factory';
import { SlowContactCardEmbeddableFactory } from '../test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory';
@ -60,7 +59,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async
/>
);
await nextTick();
await new Promise((r) => setTimeout(r, 1));
component.update();
// Due to the way embeddables mount themselves on the dom node, they are not forced to be
@ -89,7 +88,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist
<EmbeddableChildPanel container={container} embeddableId={'1'} PanelComponent={testPanel} />
);
await nextTick();
await new Promise((r) => setTimeout(r, 1));
component.update();
expect(

View file

@ -28,6 +28,17 @@ export interface ContainerInput<PanelExplicitInput = {}> extends EmbeddableInput
};
}
export interface EmbeddableContainerSettings {
/**
* If true, the container will wait for each embeddable to load after creation before loading the next embeddable.
*/
initializeSequentially?: boolean;
/**
* Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs.
*/
childIdInitializeOrder?: string[];
}
export interface IContainer<
Inherited extends {} = {},
I extends ContainerInput<Inherited> = ContainerInput<Inherited>,

View file

@ -89,6 +89,15 @@ export abstract class Embeddable<
);
}
public refreshInputFromParent() {
if (!this.parent) return;
// Make sure this panel hasn't been removed immediately after it was added, but before it finished loading.
if (!this.parent.getInput().panels[this.id]) return;
const newInput = this.parent.getInputForChild<TEmbeddableInput>(this.id);
this.onResetInput(newInput);
}
public getIsContainer(): this is IContainer {
return this.isContainer === true;
}

View file

@ -189,4 +189,6 @@ export interface IEmbeddable<
* Used to diff explicit embeddable input
*/
getExplicitInputIsEqual(lastInput: Partial<I>): Promise<boolean>;
refreshInputFromParent(): void;
}

View file

@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { Container, ViewMode, ContainerInput } from '../..';
import { HelloWorldContainerComponent } from './hello_world_container_component';
import { EmbeddableStart } from '../../../plugin';
import { EmbeddableContainerSettings } from '../../containers/i_container';
export const HELLO_WORLD_CONTAINER = 'HELLO_WORLD_CONTAINER';
@ -40,9 +41,16 @@ export class HelloWorldContainer extends Container<InheritedInput, HelloWorldCon
constructor(
input: ContainerInput<{ firstName: string; lastName: string }>,
private readonly options: HelloWorldContainerOptions
private readonly options: HelloWorldContainerOptions,
initializeSettings?: EmbeddableContainerSettings
) {
super(input, { embeddableLoaded: {} }, options.getEmbeddableFactory || (() => undefined));
super(
input,
{ embeddableLoaded: {} },
options.getEmbeddableFactory || (() => undefined),
undefined,
initializeSettings
);
}
public getInheritedInput(id: string) {

View file

@ -40,10 +40,12 @@ import { coreMock } from '../../../../core/public/mocks';
import { testPlugin } from './test_plugin';
import { of } from './helpers';
import { createEmbeddablePanelMock } from '../mocks';
import { EmbeddableContainerSettings } from '../lib/containers/i_container';
async function creatHelloWorldContainerAndEmbeddable(
containerInput: ContainerInput = { id: 'hello', panels: {} },
embeddableInput = {}
embeddableInput = {},
settings?: EmbeddableContainerSettings
) {
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
@ -69,10 +71,14 @@ async function creatHelloWorldContainerAndEmbeddable(
application: coreStart.application,
});
const container = new HelloWorldContainer(containerInput, {
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
});
const container = new HelloWorldContainer(
containerInput,
{
getEmbeddableFactory: start.getEmbeddableFactory,
panelComponent: testPanel,
},
settings
);
const embeddable = await container.addNewEmbeddable<
ContactCardEmbeddableInput,
@ -87,23 +93,123 @@ async function creatHelloWorldContainerAndEmbeddable(
return { container, embeddable, coreSetup, coreStart, setup, start, uiActions, testPanel };
}
test('Container initializes embeddables', async (done) => {
const { container } = await creatHelloWorldContainerAndEmbeddable({
id: 'hello',
panels: {
'123': {
explicitInput: { id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
},
describe('container initialization', () => {
const panels = {
'123': {
explicitInput: { id: '123' },
type: CONTACT_CARD_EMBEDDABLE,
},
});
'456': {
explicitInput: { id: '456' },
type: CONTACT_CARD_EMBEDDABLE,
},
'789': {
explicitInput: { id: '789' },
type: CONTACT_CARD_EMBEDDABLE,
},
};
if (container.getOutput().embeddableLoaded['123']) {
const expectEmbeddableLoaded = (container: HelloWorldContainer, id: string) => {
expect(container.getOutput().embeddableLoaded['123']).toBe(true);
const embeddable = container.getChild<ContactCardEmbeddable>('123');
expect(embeddable).toBeDefined();
expect(embeddable.id).toBe('123');
};
it('initializes embeddables', async (done) => {
const { container } = await creatHelloWorldContainerAndEmbeddable({
id: 'hello',
panels,
});
expectEmbeddableLoaded(container, '123');
expectEmbeddableLoaded(container, '456');
expectEmbeddableLoaded(container, '789');
done();
}
});
it('initializes embeddables in order', async (done) => {
const childIdInitializeOrder = ['456', '123', '789'];
const { container } = await creatHelloWorldContainerAndEmbeddable(
{
id: 'hello',
panels,
},
{},
{ childIdInitializeOrder }
);
const onPanelAddedMock = jest.spyOn(
container as unknown as { onPanelAdded: () => {} },
'onPanelAdded'
);
await new Promise((r) => setTimeout(r, 1));
for (const [index, orderedId] of childIdInitializeOrder.entries()) {
expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, {
explicitInput: { id: orderedId },
type: 'CONTACT_CARD_EMBEDDABLE',
});
}
done();
});
it('initializes embeddables in order with partial order arg', async (done) => {
const childIdInitializeOrder = ['789', 'idontexist'];
const { container } = await creatHelloWorldContainerAndEmbeddable(
{
id: 'hello',
panels,
},
{},
{ childIdInitializeOrder }
);
const expectedInitializeOrder = ['789', '123', '456'];
const onPanelAddedMock = jest.spyOn(
container as unknown as { onPanelAdded: () => {} },
'onPanelAdded'
);
await new Promise((r) => setTimeout(r, 1));
for (const [index, orderedId] of expectedInitializeOrder.entries()) {
expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, {
explicitInput: { id: orderedId },
type: 'CONTACT_CARD_EMBEDDABLE',
});
}
done();
});
it('initializes embeddables in order, awaiting each', async (done) => {
const childIdInitializeOrder = ['456', '123', '789'];
const { container } = await creatHelloWorldContainerAndEmbeddable(
{
id: 'hello',
panels,
},
{},
{ childIdInitializeOrder, initializeSequentially: true }
);
const onPanelAddedMock = jest.spyOn(
container as unknown as { onPanelAdded: () => {} },
'onPanelAdded'
);
const untilEmbeddableLoadedMock = jest.spyOn(container, 'untilEmbeddableLoaded');
await new Promise((r) => setTimeout(r, 10));
for (const [index, orderedId] of childIdInitializeOrder.entries()) {
await container.untilEmbeddableLoaded(orderedId);
expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, {
explicitInput: { id: orderedId },
type: 'CONTACT_CARD_EMBEDDABLE',
});
expect(untilEmbeddableLoadedMock).toHaveBeenCalledWith(orderedId);
}
done();
});
});
test('Container.addNewEmbeddable', async () => {

View file

@ -28,6 +28,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
describe('Dashboard controls integration', () => {
const clearAllControls = async () => {
const controlIds = await dashboardControls.getAllControlIds();
for (const controlId of controlIds) {
await dashboardControls.removeExistingControl(controlId);
}
};
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
@ -122,9 +129,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.controlEditorSave();
// when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view
await testSubjects.click('addFilter');
await testSubjects.missingOrFail('filterIndexPatternsSelect');
await filterBar.ensureFieldEditorModalIsClosed();
await retry.try(async () => {
await testSubjects.click('addFilter');
const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect');
await filterBar.ensureFieldEditorModalIsClosed();
expect(indexPatternSelectExists).to.be(false);
});
});
it('deletes an existing control', async () => {
@ -135,14 +145,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
after(async () => {
const controlIds = await dashboardControls.getAllControlIds();
for (const controlId of controlIds) {
await dashboardControls.removeExistingControl(controlId);
}
await clearAllControls();
});
});
describe('Interact with options list on dashboard', async () => {
describe('Interactions between options list and dashboard', async () => {
let controlId: string;
before(async () => {
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
@ -290,7 +297,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
describe('Options List validation', async () => {
describe('Options List dashboard validation', async () => {
before(async () => {
await dashboardControls.optionsListOpenPopover(controlId);
await dashboardControls.optionsListPopoverSelectOption('meow');
@ -367,6 +374,102 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await pieChart.getPieSliceCount()).to.be(1);
});
});
after(async () => {
await filterBar.removeAllFilters();
await clearAllControls();
});
});
describe('Control group hierarchical chaining', async () => {
let controlIds: string[];
const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => {
await dashboardControls.optionsListOpenPopover(controlId);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(
expectation
);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlId);
};
before(async () => {
await dashboardControls.createOptionsListControl({
dataViewTitle: 'animals-*',
fieldName: 'animal.keyword',
title: 'Animal',
});
await dashboardControls.createOptionsListControl({
dataViewTitle: 'animals-*',
fieldName: 'name.keyword',
title: 'Animal Name',
});
await dashboardControls.createOptionsListControl({
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',
title: 'Animal Sound',
});
controlIds = await dashboardControls.getAllControlIds();
});
it('Shows all available options in first Options List control', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
});
it('Selecting an option in the first Options List will filter the second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverSelectOption('cat');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']);
await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']);
});
it('Selecting an option in the second Options List will filter the third control', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[1]);
await dashboardControls.optionsListPopoverSelectOption('sylvester');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]);
await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']);
});
it('Can select an option in the third Options List', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[2]);
await dashboardControls.optionsListPopoverSelectOption('meow');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]);
});
it('Selecting a conflicting option in the first control will validate the second and third controls', async () => {
await dashboardControls.optionsListOpenPopover(controlIds[0]);
await dashboardControls.optionsListPopoverClearSelections();
await dashboardControls.optionsListPopoverSelectOption('dog');
await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]);
await ensureAvailableOptionsEql(controlIds[1], [
'Fluffy',
'Fee Fee',
'Rover',
'Ignored selection',
'sylvester',
]);
await ensureAvailableOptionsEql(controlIds[2], [
'ruff',
'bark',
'grrr',
'bow ow ow',
'grr',
'Ignored selection',
'meow',
]);
});
});
});
}

View file

@ -7,6 +7,8 @@
import expect from '@kbn/expect';
import semver from 'semver';
export default function ({ getService }) {
const supertest = getService('supertest');
@ -77,7 +79,7 @@ export default function ({ getService }) {
}
expect(panels.length).to.be(1);
expect(panels[0].type).to.be('map');
expect(panels[0].version).to.be('8.2.0');
expect(semver.gte(panels[0].version, '8.1.0')).to.be(true);
});
});
});

View file

@ -72,14 +72,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await dashboardControls.getAllControlTitles()).to.eql(['Speaker Name', 'Play Name']);
const ids = await dashboardControls.getAllControlIds();
for (const id of ids) {
await dashboardControls.optionsListOpenPopover(id);
await retry.try(async () => {
// Value counts should be 10, because there are more than 10 speakers and plays in the data set
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(10);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(id);
}
await dashboardControls.optionsListOpenPopover(ids[0]);
await retry.try(async () => {
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(10);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(ids[0]);
await dashboardControls.optionsListOpenPopover(ids[1]);
await retry.try(async () => {
// the second control should only have 5 available options because the previous control has HAMLET ROMEO JULIET and BRUTUS selected
expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5);
});
await dashboardControls.optionsListEnsurePopoverIsClosed(ids[1]);
});
it('applies default selected options list options to control', async () => {