Skip to content

Commit

Permalink
[8.x] [Lens][Embeddable] Make UI react faster to click actions like c…
Browse files Browse the repository at this point in the history
…reate or edit (elastic#210810) (elastic#212052)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Lens][Embeddable] Make UI react faster to click actions like create
or edit (elastic#210810)](elastic#210810)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Marco
Liberati","email":"dej611@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-02-20T17:19:46Z","message":"[Lens][Embeddable]
Make UI react faster to click actions like create or edit
(elastic#210810)\n\n## Summary\n\nThis PR is based on the idea in elastic#209361 and
tries to improve perceived\nperformances for all the scenarios where the
`editorFrame` is loaded.\n\nOn fast connections this is now perceived
very
fast:\n\n![esql_fast](https://github.com/user-attachments/assets/efb26416-bf15-449e-912f-a689c689c593)\n\nOn
Fast 4g is still
fast\n\n![esql_fast_4g](https://github.com/user-attachments/assets/acc199be-683d-4a4b-a53c-f37a9117c258)\n\nOn
Slow 4g is
acceptable\n\n\n![esql_slow_4g](https://github.com/user-attachments/assets/6fed9ec4-dc3f-4557-976c-91d82bddc10f)\n\nEven
on 3G connection the feedback is much better
now\n\n\n![esql_3g](https://github.com/user-attachments/assets/27e96c01-9149-4dd1-8a6d-e005202149ff)\n\nAs
a bonus extra tests have been added for the ES|QL creation flow.\n\ncc
@thomasneirynck @nreese \n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: Nick Partridge
<nick.ryan.partridge@gmail.com>","sha":"1e92ae8afbec96f437040a7d3147b20e52478833","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Team:Visualizations","release_note:skip","Feature:Lens","backport:version","v9.1.0","v8.19.0"],"title":"[Lens][Embeddable]
Make UI react faster to click actions like create or
edit","number":210810,"url":"https://github.com/elastic/kibana/pull/210810","mergeCommit":{"message":"[Lens][Embeddable]
Make UI react faster to click actions like create or edit
(elastic#210810)\n\n## Summary\n\nThis PR is based on the idea in elastic#209361 and
tries to improve perceived\nperformances for all the scenarios where the
`editorFrame` is loaded.\n\nOn fast connections this is now perceived
very
fast:\n\n![esql_fast](https://github.com/user-attachments/assets/efb26416-bf15-449e-912f-a689c689c593)\n\nOn
Fast 4g is still
fast\n\n![esql_fast_4g](https://github.com/user-attachments/assets/acc199be-683d-4a4b-a53c-f37a9117c258)\n\nOn
Slow 4g is
acceptable\n\n\n![esql_slow_4g](https://github.com/user-attachments/assets/6fed9ec4-dc3f-4557-976c-91d82bddc10f)\n\nEven
on 3G connection the feedback is much better
now\n\n\n![esql_3g](https://github.com/user-attachments/assets/27e96c01-9149-4dd1-8a6d-e005202149ff)\n\nAs
a bonus extra tests have been added for the ES|QL creation flow.\n\ncc
@thomasneirynck @nreese \n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: Nick Partridge
<nick.ryan.partridge@gmail.com>","sha":"1e92ae8afbec96f437040a7d3147b20e52478833"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/210810","number":210810,"mergeCommit":{"message":"[Lens][Embeddable]
Make UI react faster to click actions like create or edit
(elastic#210810)\n\n## Summary\n\nThis PR is based on the idea in elastic#209361 and
tries to improve perceived\nperformances for all the scenarios where the
`editorFrame` is loaded.\n\nOn fast connections this is now perceived
very
fast:\n\n![esql_fast](https://github.com/user-attachments/assets/efb26416-bf15-449e-912f-a689c689c593)\n\nOn
Fast 4g is still
fast\n\n![esql_fast_4g](https://github.com/user-attachments/assets/acc199be-683d-4a4b-a53c-f37a9117c258)\n\nOn
Slow 4g is
acceptable\n\n\n![esql_slow_4g](https://github.com/user-attachments/assets/6fed9ec4-dc3f-4557-976c-91d82bddc10f)\n\nEven
on 3G connection the feedback is much better
now\n\n\n![esql_3g](https://github.com/user-attachments/assets/27e96c01-9149-4dd1-8a6d-e005202149ff)\n\nAs
a bonus extra tests have been added for the ES|QL creation flow.\n\ncc
@thomasneirynck @nreese \n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: Nick Partridge
<nick.ryan.partridge@gmail.com>","sha":"1e92ae8afbec96f437040a7d3147b20e52478833"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
  • Loading branch information
dej611 authored Mar 3, 2025
1 parent 5f5150b commit d7aa703
Show file tree
Hide file tree
Showing 17 changed files with 301 additions and 232 deletions.
2 changes: 1 addition & 1 deletion packages/kbn-optimizer/limits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ pageLoadAssetSize:
kibanaUsageCollection: 16463
kibanaUtils: 79713
kubernetesSecurity: 77234
lens: 57135
lens: 76079
licenseManagement: 41817
licensing: 29004
links: 8000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export * from './app_plugin/save_modal_container';
export * from './chart_info_api';

export * from './trigger_actions/open_in_discover_helpers';
export * from './trigger_actions/open_lens_config/create_action_helpers';
export * from './trigger_actions/open_lens_config/in_app_embeddable_edit/in_app_embeddable_edit_action_helpers';
export { getAddLensPanelAction } from './trigger_actions/add_lens_panel_action';
export { AddESQLPanelAction } from './trigger_actions/open_lens_config/add_esql_panel_action';
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function createMockTimefilter() {
getRefreshInterval: () => {},
getRefreshIntervalDefaults: () => {},
getAutoRefreshFetch$: () => new Observable(),
getAbsoluteTime: jest.fn(),
};
}

Expand Down
20 changes: 7 additions & 13 deletions x-pack/platform/plugins/shared/lens/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,12 @@ export class LensPlugin {
// Let Dashboard know about the Lens panel type
embeddable.registerAddFromLibraryType<LensSavedObjectAttributes>({
onAdd: async (container, savedObject) => {
const { attributeService } = await getStartServicesForEmbeddable();
const services = await getStartServicesForEmbeddable();
// deserialize the saved object from visualize library
// this make sure to fit into the new embeddable model, where the following build()
// function expects a fully loaded runtime state
const state = await deserializeState(
attributeService,
services,
{ savedObjectId: savedObject.id },
savedObject.references
);
Expand Down Expand Up @@ -542,9 +542,6 @@ export class LensPlugin {
this.editorFrameService!.loadVisualizations(),
this.editorFrameService!.loadDatasources(),
]);
const { setVisualizationMap, setDatasourceMap } = await import('./async_services');
setDatasourceMap(datasourceMap);
setVisualizationMap(visualizationMap);
return { datasourceMap, visualizationMap };
};

Expand Down Expand Up @@ -675,7 +672,10 @@ export class LensPlugin {
);

// Allows the Lens embeddable to easily open the inline editing flyout
const editLensEmbeddableAction = new EditLensEmbeddableAction(startDependencies, core);
const editLensEmbeddableAction = new EditLensEmbeddableAction(core, async () => {
const { visualizationMap, datasourceMap } = await this.initEditorFrameService();
return { ...startDependencies, visualizationMap, datasourceMap };
});
// embeddable inline edit panel action
startDependencies.uiActions.addTriggerAction(
IN_APP_EMBEDDABLE_EDIT_TRIGGER,
Expand All @@ -687,13 +687,7 @@ export class LensPlugin {
ACTION_CREATE_ESQL_CHART,
async () => {
const { AddESQLPanelAction } = await import('./async_services');
return new AddESQLPanelAction(startDependencies, core, async () => {
if (!this.editorFrameService) {
await this.initEditorFrameService();
}

return this.editorFrameService!;
});
return new AddESQLPanelAction(core);
}
);
startDependencies.uiActions.registerActionAsync('addLensPanelAction', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { loadESQLAttributes } from './esql';
import { makeEmbeddableServices } from './mocks';
import { LensEmbeddableStartServices } from './types';
import { coreMock } from '@kbn/core/public/mocks';
import { BehaviorSubject } from 'rxjs';
import * as suggestionModule from '../lens_suggestions_api';
// Need to do this magic in order to spy on specific functions
import * as esqlUtils from '@kbn/esql-utils';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
jest.mock('@kbn/esql-utils', () => ({
__esModule: true,
...jest.requireActual('@kbn/esql-utils'),
}));

function getUiSettingsOverrides() {
const core = coreMock.createStart({ basePath: '/testbasepath' });
return core.uiSettings;
}

describe('ES|QL attributes creation', () => {
function getServices(servicesOverrides?: Partial<LensEmbeddableStartServices>) {
return {
...makeEmbeddableServices(new BehaviorSubject<string>(''), undefined, {
visOverrides: { id: 'lnsXY' },
dataOverrides: { id: 'form_based' },
}),
uiSettings: { ...getUiSettingsOverrides(), get: jest.fn().mockReturnValue(true) },
...servicesOverrides,
};
}
it('should not update the attributes if no index is available', async () => {
jest.spyOn(esqlUtils, 'getIndexForESQLQuery').mockResolvedValueOnce(null);

const attributes = await loadESQLAttributes(getServices());
expect(attributes).toBeUndefined();
});

it('should not update the attributes if no suggestion is generated', async () => {
jest.spyOn(esqlUtils, 'getIndexForESQLQuery').mockResolvedValueOnce('index');
jest.spyOn(esqlUtils, 'getESQLAdHocDataview').mockResolvedValueOnce(dataViewMock);
jest.spyOn(esqlUtils, 'getESQLQueryColumns').mockResolvedValueOnce([]);
jest.spyOn(suggestionModule, 'suggestionsApi').mockReturnValue([]);

const attributes = await loadESQLAttributes(getServices());
expect(attributes).toBeUndefined();
});

it('should update the attributes if there is a valid suggestion', async () => {
jest.spyOn(esqlUtils, 'getIndexForESQLQuery').mockResolvedValueOnce('index');
jest.spyOn(esqlUtils, 'getESQLAdHocDataview').mockResolvedValueOnce(dataViewMock);
jest.spyOn(esqlUtils, 'getESQLQueryColumns').mockResolvedValueOnce([]);
jest.spyOn(suggestionModule, 'suggestionsApi').mockReturnValue([
{
title: 'MyTitle',
visualizationId: 'lnsXY',
datasourceId: 'form_based',
datasourceState: {},
visualizationState: {},
columns: 1,
score: 1,
previewIcon: 'icon',
changeType: 'initial',
keptLayerIds: [],
},
]);

const attributes = await loadESQLAttributes(getServices());
expect(attributes).not.toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import {
getIndexForESQLQuery,
getESQLAdHocDataview,
getInitialESQLQuery,
getESQLQueryColumns,
} from '@kbn/esql-utils';
import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils';
import { isESQLModeEnabled } from './initializers/utils';
import type { LensEmbeddableStartServices } from './types';

export async function loadESQLAttributes({
dataViews,
data,
visualizationMap,
datasourceMap,
uiSettings,
}: Pick<
LensEmbeddableStartServices,
'dataViews' | 'data' | 'visualizationMap' | 'datasourceMap' | 'uiSettings'
>) {
// Early exit if ESQL is not supported
if (!isESQLModeEnabled({ uiSettings })) {
return;
}
const indexName = await getIndexForESQLQuery({ dataViews });
// Early exit if there's no data view to use
if (!indexName) {
return;
}

// From this moment on there are no longer early exists before suggestions
// so make sure to load async modules while doing other async stuff to save some time
const [dataView, { suggestionsApi }] = await Promise.all([
getESQLAdHocDataview(`from ${indexName}`, dataViews),
import('../async_services'),
]);

const esqlQuery = getInitialESQLQuery(dataView);

const defaultEsqlQuery = {
esql: esqlQuery,
};

// For the suggestions api we need only the columns
// so we are requesting them with limit 0
// this is much more performant than requesting
// all the table
const abortController = new AbortController();
const columns = await getESQLQueryColumns({
esqlQuery,
search: data.search.search,
signal: abortController.signal,
timeRange: data.query.timefilter.timefilter.getAbsoluteTime(),
});

const context = {
dataViewSpec: dataView.toSpec(false),
fieldName: '',
textBasedColumns: columns,
query: defaultEsqlQuery,
};

// get the initial attributes from the suggestions api
const allSuggestions =
suggestionsApi({ context, dataView, datasourceMap, visualizationMap }) ?? [];

// Lens might not return suggestions for some cases, i.e. in case of errors
if (!allSuggestions.length) {
return;
}
const [firstSuggestion] = allSuggestions;
return getLensAttributesFromSuggestion({
filters: [],
query: defaultEsqlQuery,
suggestion: {
...firstSuggestion,
title: '', // when creating a new panel, we don't want to use the title from the suggestion
},
dataView,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { defaultDoc, makeAttributeService } from '../mocks/services_mock';
import { BehaviorSubject } from 'rxjs';
import { defaultDoc } from '../mocks/services_mock';
import { deserializeState } from './helper';
import { makeEmbeddableServices } from './mocks';

describe('Embeddable helpers', () => {
describe('deserializeState', () => {
function getServices() {
return makeEmbeddableServices(new BehaviorSubject<string>(''), undefined, {
visOverrides: { id: 'lnsXY' },
dataOverrides: { id: 'form_based' },
});
}
it('should forward a by value raw state', async () => {
const attributeService = makeAttributeService(defaultDoc);
const services = getServices();
const rawState = {
attributes: defaultDoc,
};
const runtimeState = await deserializeState(attributeService, rawState);
const runtimeState = await deserializeState(services, rawState);
expect(runtimeState).toEqual(rawState);
});

it('should wrap Lens doc/attributes into component state shape', async () => {
const attributeService = makeAttributeService(defaultDoc);
const runtimeState = await deserializeState(attributeService, defaultDoc);
const services = getServices();
const runtimeState = await deserializeState(services, defaultDoc);
expect(runtimeState).toEqual(
expect.objectContaining({
attributes: { ...defaultDoc, references: defaultDoc.references },
Expand All @@ -30,18 +37,20 @@ describe('Embeddable helpers', () => {
});

it('load a by-ref doc from the attribute service', async () => {
const attributeService = makeAttributeService(defaultDoc);
await deserializeState(attributeService, {
const services = getServices();
await deserializeState(services, {
savedObjectId: '123',
});

expect(attributeService.loadFromLibrary).toHaveBeenCalledWith('123');
expect(services.attributeService.loadFromLibrary).toHaveBeenCalledWith('123');
});

it('should fallback to an empty Lens doc if the saved object is not found', async () => {
const attributeService = makeAttributeService(defaultDoc);
attributeService.loadFromLibrary.mockRejectedValueOnce(new Error('not found'));
const runtimeState = await deserializeState(attributeService, {
const services = getServices();
services.attributeService.loadFromLibrary = jest
.fn()
.mockRejectedValueOnce(new Error('not found'));
const runtimeState = await deserializeState(services, {
savedObjectId: '123',
});
// check the visualizationType set to null for empty state
Expand All @@ -55,51 +64,51 @@ describe('Embeddable helpers', () => {
// * other space for a by-value with new ref ids

it('should inject correctly serialized references into runtime state for a by value in the default space', async () => {
const attributeService = makeAttributeService(defaultDoc);
const services = getServices();
const mockedReferences = [
{ id: 'serializedRefs', name: 'index-pattern-0', type: 'mocked-reference' },
];
const runtimeState = await deserializeState(
attributeService,
services,
{
attributes: defaultDoc,
},
mockedReferences
);
expect(attributeService.injectReferences).toHaveBeenCalled();
expect(services.attributeService.injectReferences).toHaveBeenCalled();
expect(runtimeState.attributes.references).toEqual(mockedReferences);
});

it('should inject correctly serialized references into runtime state for a by ref in the default space', async () => {
const attributeService = makeAttributeService(defaultDoc);
const services = getServices();
const mockedReferences = [
{ id: 'serializedRefs', name: 'index-pattern-0', type: 'mocked-reference' },
];
const runtimeState = await deserializeState(
attributeService,
services,
{
savedObjectId: '123',
},
mockedReferences
);
expect(attributeService.injectReferences).not.toHaveBeenCalled();
expect(services.attributeService.injectReferences).not.toHaveBeenCalled();
// Note the original references should be kept
expect(runtimeState.attributes.references).toEqual(defaultDoc.references);
});

it('should inject correctly serialized references into runtime state for a by value in another space', async () => {
const attributeService = makeAttributeService(defaultDoc);
const services = getServices();
const mockedReferences = [
{ id: 'serializedRefs', name: 'index-pattern-0', type: 'mocked-reference' },
];
const runtimeState = await deserializeState(
attributeService,
services,
{
attributes: defaultDoc,
},
mockedReferences
);
expect(attributeService.injectReferences).toHaveBeenCalled();
expect(services.attributeService.injectReferences).toHaveBeenCalled();
// note: in this case the references are swapped
expect(runtimeState.attributes.references).toEqual(mockedReferences);
});
Expand Down
Loading

0 comments on commit d7aa703

Please sign in to comment.