[Lens] Expose events from lens for lens embeddable (#94670)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2021-03-30 15:19:35 +02:00 committed by GitHub
parent c6b37dec70
commit 9d8a2f183e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 317 additions and 57 deletions

View file

@ -111,7 +111,13 @@ export const App = (props: {
defaultIndexPattern: IndexPattern | null;
}) => {
const [color, setColor] = useState('green');
const [isLoading, setIsLoading] = useState(false);
const LensComponent = props.plugins.lens.EmbeddableComponent;
const [time, setTime] = useState({
from: 'now-5d',
to: 'now',
});
return (
<EuiPage>
<EuiPageBody style={{ maxWidth: 1200, margin: '0 auto' }}>
@ -138,6 +144,7 @@ export const App = (props: {
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={isLoading}
onClick={() => {
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
@ -153,10 +160,7 @@ export const App = (props: {
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor({
id: '',
timeRange: {
from: 'now-5d',
to: 'now',
},
timeRange: time,
attributes: getLensAttributes(props.defaultIndexPattern!, color),
});
// eslint-disable-next-line no-bitwise
@ -171,11 +175,23 @@ export const App = (props: {
<LensComponent
id=""
style={{ height: 500 }}
timeRange={{
from: 'now-5d',
to: 'now',
}}
timeRange={time}
attributes={getLensAttributes(props.defaultIndexPattern, color)}
onLoad={(val) => {
setIsLoading(val);
}}
onBrushEnd={({ range }) => {
setTime({
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
});
}}
onFilter={(_data) => {
// call back event for on filter event
}}
onTableRowClick={(_data) => {
// call back event for on table row click event
}}
/>
</>
) : (

View file

@ -679,4 +679,205 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(1);
});
it('should call onload after rerender and onData$ call ', async () => {
const onLoad = jest.fn();
expressionRenderer = jest.fn(({ onData$ }) => {
setTimeout(() => {
onData$?.({});
}, 10);
return null;
});
const embeddable = new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
},
({ id: '123', onLoad } as unknown) as LensEmbeddableInput
);
await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput);
embeddable.render(mountpoint);
expect(onLoad).toHaveBeenCalledWith(true);
expect(onLoad).toHaveBeenCalledTimes(1);
await new Promise((resolve) => setTimeout(resolve, 20));
// loading should become false
expect(onLoad).toHaveBeenCalledTimes(2);
expect(onLoad).toHaveBeenNthCalledWith(2, false);
expect(expressionRenderer).toHaveBeenCalledTimes(1);
embeddable.updateInput({
searchSessionId: 'newSession',
});
embeddable.reload();
// loading should become again true
expect(onLoad).toHaveBeenCalledTimes(3);
expect(onLoad).toHaveBeenNthCalledWith(3, true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(expressionRenderer).toHaveBeenCalledTimes(2);
await new Promise((resolve) => setTimeout(resolve, 20));
// loading should again become false
expect(onLoad).toHaveBeenCalledTimes(4);
expect(onLoad).toHaveBeenNthCalledWith(4, false);
});
it('should call onFilter event on filter call ', async () => {
const onFilter = jest.fn();
expressionRenderer = jest.fn(({ onEvent }) => {
setTimeout(() => {
onEvent?.({ name: 'filter', data: { pings: false } });
}, 10);
return null;
});
const embeddable = new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
},
({ id: '123', onFilter } as unknown) as LensEmbeddableInput
);
await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput);
embeddable.render(mountpoint);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onFilter).toHaveBeenCalledWith({ pings: false });
expect(onFilter).toHaveBeenCalledTimes(1);
});
it('should call onBrush event on brushing', async () => {
const onBrushEnd = jest.fn();
expressionRenderer = jest.fn(({ onEvent }) => {
setTimeout(() => {
onEvent?.({ name: 'brush', data: { range: [0, 1] } });
}, 10);
return null;
});
const embeddable = new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
},
({ id: '123', onBrushEnd } as unknown) as LensEmbeddableInput
);
await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput);
embeddable.render(mountpoint);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onBrushEnd).toHaveBeenCalledWith({ range: [0, 1] });
expect(onBrushEnd).toHaveBeenCalledTimes(1);
});
it('should call onTableRowClick event ', async () => {
const onTableRowClick = jest.fn();
expressionRenderer = jest.fn(({ onEvent }) => {
setTimeout(() => {
onEvent?.({ name: 'tableRowContextMenuClick', data: { name: 'test' } });
}, 10);
return null;
});
const embeddable = new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService,
expressionRenderer,
basePath,
indexPatternService: {} as IndexPatternsContract,
editable: true,
getTrigger,
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
},
({ id: '123', onTableRowClick } as unknown) as LensEmbeddableInput
);
await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput);
embeddable.render(mountpoint);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onTableRowClick).toHaveBeenCalledWith({ name: 'test' });
expect(onTableRowClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -45,6 +45,9 @@ import {
isLensBrushEvent,
isLensFilterEvent,
isLensTableRowContextMenuClickEvent,
LensBrushEvent,
LensFilterEvent,
LensTableRowContextMenuEvent,
} from '../../types';
import { IndexPatternsContract } from '../../../../../../src/plugins/data/public';
@ -63,6 +66,10 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
renderMode?: RenderMode;
style?: React.CSSProperties;
className?: string;
onBrushEnd?: (data: LensBrushEvent['data']) => void;
onLoad?: (isLoading: boolean) => void;
onFilter?: (data: LensFilterEvent['data']) => void;
onTableRowClick?: (data: LensTableRowContextMenuEvent['data']) => void;
}
export type LensByValueInput = {
@ -103,6 +110,8 @@ export class Embeddable
private isInitialized = false;
private activeData: Partial<DefaultInspectorAdapters> | undefined;
private errors: ErrorMessage[] | undefined;
private inputReloadSubscriptions: Subscription[];
private isDestroyed?: boolean;
private externalSearchContext: {
timeRange?: TimeRange;
@ -133,65 +142,76 @@ export class Embeddable
const input$ = this.getInput$();
this.inputReloadSubscriptions = [];
// Lens embeddable does not re-render when embeddable input changes in
// general, to improve performance. This line makes sure the Lens embeddable
// re-renders when anything in ".dynamicActions" (e.g. drilldowns) changes.
input$
.pipe(
map((input) => input.enhancements?.dynamicActions),
distinctUntilChanged((a, b) => isEqual(a, b)),
skip(1)
)
.subscribe((input) => {
this.reload();
});
this.inputReloadSubscriptions.push(
input$
.pipe(
map((input) => input.enhancements?.dynamicActions),
distinctUntilChanged((a, b) => isEqual(a, b)),
skip(1)
)
.subscribe((input) => {
this.reload();
})
);
// Lens embeddable does not re-render when embeddable input changes in
// general, to improve performance. This line makes sure the Lens embeddable
// re-renders when dashboard view mode switches between "view/edit". This is
// needed to see the changes to ".dynamicActions" (e.g. drilldowns) when
// dashboard's mode is toggled.
input$
.pipe(
map((input) => input.viewMode),
distinctUntilChanged(),
skip(1)
)
.subscribe((input) => {
this.reload();
});
this.inputReloadSubscriptions.push(
input$
.pipe(
map((input) => input.viewMode),
distinctUntilChanged(),
skip(1)
)
.subscribe((input) => {
this.reload();
})
);
// Re-initialize the visualization if either the attributes or the saved object id changes
input$
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
['attributes' in a && a.attributes, 'savedObjectId' in a && a.savedObjectId],
['attributes' in b && b.attributes, 'savedObjectId' in b && b.savedObjectId]
)
),
skip(1)
)
.subscribe(async (input) => {
await this.initializeSavedVis(input);
this.reload();
});
this.inputReloadSubscriptions.push(
input$
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
['attributes' in a && a.attributes, 'savedObjectId' in a && a.savedObjectId],
['attributes' in b && b.attributes, 'savedObjectId' in b && b.savedObjectId]
)
),
skip(1)
)
.subscribe(async (input) => {
await this.initializeSavedVis(input);
this.reload();
})
);
// Update search context and reload on changes related to search
this.getUpdated$()
.pipe(map(() => this.getInput()))
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
[a.filters, a.query, a.timeRange, a.searchSessionId],
[b.filters, b.query, b.timeRange, b.searchSessionId]
)
),
skip(1)
)
.subscribe(async (input) => {
this.onContainerStateChanged(input);
});
this.inputReloadSubscriptions.push(
this.getUpdated$()
.pipe(map(() => this.getInput()))
.pipe(
distinctUntilChanged((a, b) =>
isEqual(
[a.filters, a.query, a.timeRange, a.searchSessionId],
[b.filters, b.query, b.timeRange, b.searchSessionId]
)
),
skip(1)
)
.subscribe(async (input) => {
this.onContainerStateChanged(input);
})
);
}
public supportedTriggers() {
@ -222,7 +242,7 @@ export class Embeddable
this.onFatalError(e);
return false;
});
if (!attributes) {
if (!attributes || this.isDestroyed) {
return;
}
this.savedVis = {
@ -268,6 +288,10 @@ export class Embeddable
inspectorAdapters?: Partial<DefaultInspectorAdapters> | undefined
) => {
this.activeData = inspectorAdapters;
if (this.input.onLoad) {
// once onData$ is get's called from expression renderer, loading becomes false
this.input.onLoad(false);
}
};
/**
@ -277,9 +301,12 @@ export class Embeddable
*/
render(domNode: HTMLElement | Element) {
this.domNode = domNode;
if (!this.savedVis || !this.isInitialized) {
if (!this.savedVis || !this.isInitialized || this.isDestroyed) {
return;
}
if (this.input.onLoad) {
this.input.onLoad(true);
}
const input = this.getInput();
render(
<ExpressionWrapper
@ -356,12 +383,19 @@ export class Embeddable
data: event.data,
embeddable: this,
});
if (this.input.onBrushEnd) {
this.input.onBrushEnd(event.data);
}
}
if (isLensFilterEvent(event)) {
this.deps.getTrigger(VIS_EVENT_TO_TRIGGER[event.name]).exec({
data: event.data,
embeddable: this,
});
if (this.input.onFilter) {
this.input.onFilter(event.data);
}
}
if (isLensTableRowContextMenuClickEvent(event)) {
@ -372,11 +406,14 @@ export class Embeddable
},
true
);
if (this.input.onTableRowClick) {
this.input.onTableRowClick((event.data as unknown) as LensTableRowContextMenuEvent['data']);
}
}
};
async reload() {
if (!this.savedVis || !this.isInitialized) {
if (!this.savedVis || !this.isInitialized || this.isDestroyed) {
return;
}
this.handleContainerStateChanged(this.input);
@ -445,6 +482,12 @@ export class Embeddable
destroy() {
super.destroy();
this.isDestroyed = true;
if (this.inputReloadSubscriptions.length > 0) {
this.inputReloadSubscriptions.forEach((reloadSub) => {
reloadSub.unsubscribe();
});
}
if (this.domNode) {
unmountComponentAtNode(this.domNode);
}