mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Dashboard][Controls] Hierarchical Chaining (#126649)
* Hierarchical Chaining Implementation
This commit is contained in:
parent
93704a7a0b
commit
2bf66ffe91
24 changed files with 752 additions and 203 deletions
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -189,4 +189,6 @@ export interface IEmbeddable<
|
|||
* Used to diff explicit embeddable input
|
||||
*/
|
||||
getExplicitInputIsEqual(lastInput: Partial<I>): Promise<boolean>;
|
||||
|
||||
refreshInputFromParent(): void;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue