mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
Changes the versioning scheme used by Dashboard Panels and by value Embeddables, and introduces a new clientside system that can migrate Embeddable Inputs to their latest versions.
321 lines
9.9 KiB
TypeScript
321 lines
9.9 KiB
TypeScript
/*
|
|
* 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 fastIsEqual from 'fast-deep-equal';
|
|
import { cloneDeep } from 'lodash';
|
|
import * as Rx from 'rxjs';
|
|
import { merge } from 'rxjs';
|
|
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
|
|
import { RenderCompleteDispatcher } from '@kbn/kibana-utils-plugin/public';
|
|
import { Adapters } from '../types';
|
|
import { IContainer } from '../containers';
|
|
import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable';
|
|
import { EmbeddableInput, ViewMode } from '../../../common/types';
|
|
import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input';
|
|
|
|
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
|
|
if (input.hidePanelTitles) return '';
|
|
return input.title ?? output.defaultTitle;
|
|
}
|
|
function getPanelDescription(input: EmbeddableInput, output: EmbeddableOutput) {
|
|
if (input.hidePanelTitles) return '';
|
|
return input.description ?? output.defaultDescription;
|
|
}
|
|
|
|
export abstract class Embeddable<
|
|
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
|
|
TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput,
|
|
TNode = any
|
|
> implements IEmbeddable<TEmbeddableInput, TEmbeddableOutput, TNode>
|
|
{
|
|
static runtimeId: number = 0;
|
|
|
|
public readonly runtimeId = Embeddable.runtimeId++;
|
|
|
|
public readonly parent?: IContainer;
|
|
public readonly isContainer: boolean = false;
|
|
public readonly deferEmbeddableLoad: boolean = false;
|
|
public catchError?(error: EmbeddableError, domNode: HTMLElement | Element): TNode | (() => void);
|
|
|
|
public abstract readonly type: string;
|
|
public readonly id: string;
|
|
public fatalError?: Error;
|
|
|
|
protected output: TEmbeddableOutput;
|
|
protected input: TEmbeddableInput;
|
|
|
|
private readonly inputSubject = new Rx.ReplaySubject<TEmbeddableInput>(1);
|
|
private readonly outputSubject = new Rx.ReplaySubject<TEmbeddableOutput>(1);
|
|
private readonly input$ = this.inputSubject.asObservable();
|
|
private readonly output$ = this.outputSubject.asObservable();
|
|
|
|
protected renderComplete = new RenderCompleteDispatcher();
|
|
|
|
// Listener to parent changes, if this embeddable exists in a parent, in order
|
|
// to update input when the parent changes.
|
|
private parentSubscription?: Rx.Subscription;
|
|
|
|
protected destroyed: boolean = false;
|
|
|
|
constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) {
|
|
this.id = input.id;
|
|
|
|
this.output = {
|
|
title: getPanelTitle(input, output),
|
|
description: getPanelDescription(input, output),
|
|
...(this.reportsEmbeddableLoad()
|
|
? {}
|
|
: {
|
|
loading: false,
|
|
rendered: true,
|
|
}),
|
|
...output,
|
|
};
|
|
this.input = {
|
|
viewMode: ViewMode.EDIT,
|
|
...input,
|
|
};
|
|
this.parent = parent;
|
|
|
|
this.inputSubject.next(this.input);
|
|
this.outputSubject.next(this.output);
|
|
|
|
if (parent) {
|
|
this.parentSubscription = Rx.merge(parent.getInput$(), parent.getOutput$()).subscribe(() => {
|
|
// Make sure this panel hasn't been removed immediately after it was added, but before it finished loading.
|
|
if (!parent.getInput().panels[this.id]) return;
|
|
|
|
const newInput = parent.getInputForChild<TEmbeddableInput>(this.id);
|
|
this.onResetInput(newInput);
|
|
});
|
|
}
|
|
this.getOutput$()
|
|
.pipe(
|
|
map(({ title }) => title || ''),
|
|
distinctUntilChanged()
|
|
)
|
|
.subscribe((title) => this.renderComplete.setTitle(title));
|
|
}
|
|
|
|
public reportsEmbeddableLoad() {
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Reload will be called when there is a request to refresh the data or view, even if the
|
|
* input data did not change.
|
|
*
|
|
* In case if input data did change and reload is requested input$ and output$ would still emit before `reload` is called
|
|
*
|
|
* The order would be as follows:
|
|
* input$
|
|
* output$
|
|
* reload()
|
|
* ----
|
|
* updated$
|
|
*/
|
|
public abstract reload(): void;
|
|
|
|
/**
|
|
* Merges input$ and output$ streams and debounces emit till next macro-task.
|
|
* Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously.
|
|
* In case corresponding state change triggered `reload` this stream is guarantied to emit later,
|
|
* which allows to skip state handling in case `reload` already handled it.
|
|
*/
|
|
public getUpdated$(): Readonly<Rx.Observable<TEmbeddableInput | TEmbeddableOutput>> {
|
|
return merge(this.getInput$().pipe(skip(1)), this.getOutput$().pipe(skip(1))).pipe(
|
|
debounceTime(0)
|
|
);
|
|
}
|
|
|
|
public getInput$(): Readonly<Rx.Observable<TEmbeddableInput>> {
|
|
return this.input$;
|
|
}
|
|
|
|
public getOutput$(): Readonly<Rx.Observable<TEmbeddableOutput>> {
|
|
return this.output$;
|
|
}
|
|
|
|
public getOutput(): Readonly<TEmbeddableOutput> {
|
|
return this.output;
|
|
}
|
|
|
|
public async getExplicitInputIsEqual(
|
|
lastExplicitInput: Partial<TEmbeddableInput>
|
|
): Promise<boolean> {
|
|
const currentExplicitInput = this.getExplicitInput();
|
|
return (
|
|
genericEmbeddableInputIsEqual(lastExplicitInput, currentExplicitInput) &&
|
|
fastIsEqual(
|
|
omitGenericEmbeddableInput(lastExplicitInput),
|
|
omitGenericEmbeddableInput(currentExplicitInput)
|
|
)
|
|
);
|
|
}
|
|
|
|
public getExplicitInput() {
|
|
const root = this.getRoot();
|
|
if (root.getIsContainer()) {
|
|
return (
|
|
(root.getInput().panels?.[this.id]?.explicitInput as TEmbeddableInput) ?? this.getInput()
|
|
);
|
|
}
|
|
return this.getInput();
|
|
}
|
|
|
|
public getPersistableInput() {
|
|
return this.getExplicitInput();
|
|
}
|
|
|
|
public getInput(): Readonly<TEmbeddableInput> {
|
|
return this.input;
|
|
}
|
|
|
|
public getTitle(): string {
|
|
return this.output.title ?? '';
|
|
}
|
|
|
|
public getDescription(): string {
|
|
return this.output.description ?? '';
|
|
}
|
|
|
|
/**
|
|
* Returns the top most parent embeddable, or itself if this embeddable
|
|
* is not within a parent.
|
|
*/
|
|
public getRoot(): IEmbeddable | IContainer {
|
|
let root: IEmbeddable | IContainer = this;
|
|
while (root.parent) {
|
|
root = root.parent;
|
|
}
|
|
return root;
|
|
}
|
|
|
|
public updateInput(changes: Partial<TEmbeddableInput>): void {
|
|
if (this.destroyed) {
|
|
throw new Error('Embeddable has been destroyed');
|
|
}
|
|
if (this.parent) {
|
|
// Ensures state changes flow from container downward.
|
|
this.parent.updateInputForChild<TEmbeddableInput>(this.id, changes);
|
|
} else {
|
|
this.onInputChanged(changes);
|
|
}
|
|
}
|
|
|
|
public render(el: HTMLElement): TNode | void {
|
|
this.renderComplete.setEl(el);
|
|
this.renderComplete.setTitle(this.output.title || '');
|
|
|
|
if (this.destroyed) {
|
|
throw new Error('Embeddable has been destroyed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An embeddable can return inspector adapters if it want the inspector to be
|
|
* available via the context menu of that panel.
|
|
* @return Inspector adapters that will be used to open an inspector for.
|
|
*/
|
|
public getInspectorAdapters(): Adapters | undefined {
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Called when this embeddable is no longer used, this should be the place for
|
|
* implementors to add additional clean up tasks, like un-mounting and unsubscribing.
|
|
*/
|
|
public destroy(): void {
|
|
this.destroyed = true;
|
|
|
|
this.inputSubject.complete();
|
|
this.outputSubject.complete();
|
|
|
|
if (this.parentSubscription) {
|
|
this.parentSubscription.unsubscribe();
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* communicate to the parent embeddable that this embeddable's initialization is finished.
|
|
* This only applies to embeddables which defer their loading state with deferEmbeddableLoad.
|
|
*/
|
|
protected setInitializationFinished() {
|
|
if (this.deferEmbeddableLoad && this.parent?.isContainer) {
|
|
this.parent.setChildLoaded(this);
|
|
}
|
|
}
|
|
|
|
public updateOutput(outputChanges: Partial<TEmbeddableOutput>): void {
|
|
const newOutput = {
|
|
...this.output,
|
|
...outputChanges,
|
|
};
|
|
if (!fastIsEqual(this.output, newOutput)) {
|
|
this.output = newOutput;
|
|
this.outputSubject.next(this.output);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call this **only** when your embeddable has encountered a non-recoverable error; recoverable errors
|
|
* should be handled by the individual embeddable types
|
|
* @param e The fatal, unrecoverable Error that was thrown
|
|
*/
|
|
protected onFatalError(e: Error) {
|
|
this.fatalError = e;
|
|
this.outputSubject.error(e);
|
|
// if the container is waiting for this embeddable to complete loading,
|
|
// a fatal error counts as complete.
|
|
if (this.deferEmbeddableLoad && this.parent?.isContainer) {
|
|
this.parent.setChildLoaded(this);
|
|
}
|
|
}
|
|
|
|
private onResetInput(newInput: TEmbeddableInput) {
|
|
if (!fastIsEqual(this.input, newInput)) {
|
|
const oldLastReloadRequestTime = this.input.lastReloadRequestTime;
|
|
this.input = newInput;
|
|
this.inputSubject.next(newInput);
|
|
this.updateOutput({
|
|
title: getPanelTitle(this.input, this.output),
|
|
description: getPanelDescription(this.input, this.output),
|
|
} as Partial<TEmbeddableOutput>);
|
|
if (oldLastReloadRequestTime !== newInput.lastReloadRequestTime) {
|
|
this.reload();
|
|
}
|
|
}
|
|
}
|
|
|
|
private onInputChanged(changes: Partial<TEmbeddableInput>) {
|
|
const newInput = cloneDeep({
|
|
...this.input,
|
|
...changes,
|
|
});
|
|
|
|
this.onResetInput(newInput);
|
|
}
|
|
|
|
public supportedTriggers(): string[] {
|
|
return [];
|
|
}
|
|
}
|