From f78bb916aea9ffe1bb54540fd799a1218ce5cb0c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 18 Jan 2023 18:05:26 +0100 Subject: [PATCH] [Lens] Share link feature (#148829) ## Summary Fixes #75316 * Lens * [x] Refactored Top nav actions code to be more modular * [x] Created new Locator object for Lens * [x] Enabled server side to make Short URL work with it * [x] Added unit tests for it * [x] Extended `getEditPath` to support `filters` and `refreshInterval` * [x] Extended mounting code in Lens to handle a new type of incoming context * [x] Add new Share action * [x] Added new `objectTypeTitle` prop to have custom titles on Share popover * [x] Replaced the `Download CSV` action and move it as menu item * [x] Refactor code into share item provider + (lazy) panel content * [x] Add debug flag to make CSV download testable * [x] Add functional tests for CSV download * [x] Add Permalink action * [x] Integrate Permalink with Short URL service * [x] Tweaked Permalink action to work with SO custom URL * [x] Tweaked Permalink action to handle disabled state * [x] Updated unit tests with new features * [x] Added (basic) caching logic to avoid too many snapshot duplicate Short URLs * [x] New share function test suite created * [x] Extended `browser` service with a new method to have a blank tab in browser * [x] New helper functions in Lens to test Share feature Screenshot 2023-01-11 at 12 58 30 Screenshot 2023-01-11 at 12 58 36 Screenshot 2023-01-11 at 12 58 40 Screenshot 2023-01-11 at 12 58 46 Screenshot 2023-01-11 at 12 59 03 ### Notes #### Short URL requirement This feature strictly requires the ShortURL service to be enabled to work, otherwise the permalink feature is disabled for snapshot sharing. This requirement is not clearly stated in the Share menu (yet) like other app do as the Sharing flow had to be customised in Lens due to some other technical challenges. Would it be ok to workout a UI improvement as follow up? #### Context tech debt The way the locator works as injecting the shared state into the context produced a discrete amount of branching, due to inconsistency of the `context` type coming from different sources (Discover, Agg-based/TSVB, Lens itself...). Perhaps it's worth discussing having a refactoring of the context type here? #### Missing locator service Due to the way the sharing logic works in Lens the locator has not been exported from the `plugin` functions. I thought to add a custom function for it, but perhaps we could investigate a bit better whether this is needed and eventually its implementation in a follow up task. ## How is the snapshot URL generated? ```mermaid sequenceDiagram actor User User->>Share Snapshot URL: click Share Snapshot URL->> Lens ShortUrlService: Lens state Lens ShortUrlService->> Lens ShortUrlService: Check cache based on state Lens ShortUrlService->> ShortUrlService: Generate a Short URL ShortUrlService->> Lens ShortUrlService: new Short URL Lens ShortUrlService->> Application `getUrlForApp`: Build absolute URL Application `getUrlForApp`->> Lens ShortUrlService: final URL Lens ShortUrlService->>Share Snapshot URL: final URL Share Snapshot URL->>User: final URL copied in clipboard ``` ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Kaarina Tungseth Co-authored-by: Stratoula Kalafateli Co-authored-by: Andrew Tate Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../share_context_menu.test.tsx.snap | 130 +++++ .../components/share_context_menu.test.tsx | 16 + .../public/components/share_context_menu.tsx | 8 +- .../components/url_panel_content.test.tsx | 16 + .../public/components/url_panel_content.tsx | 13 +- .../public/services/share_menu_manager.tsx | 6 + src/plugins/share/public/types.ts | 3 + test/functional/services/common/browser.ts | 8 + x-pack/plugins/lens/common/constants.ts | 32 +- x-pack/plugins/lens/common/helpers.test.ts | 73 +++ .../lens/common/locator/locator.test.ts | 172 ++++++ x-pack/plugins/lens/common/locator/locator.ts | 215 +++++++ .../lens/public/app_plugin/app.test.tsx | 56 +- x-pack/plugins/lens/public/app_plugin/app.tsx | 44 +- .../csv_download_panel_content.tsx | 52 ++ .../csv_download_panel_content_lazy.tsx | 39 ++ .../csv_download_provider.tsx | 155 +++++ .../lens/public/app_plugin/lens_top_nav.tsx | 535 ++++++++++-------- .../lens/public/app_plugin/mounter.tsx | 77 ++- .../lens/public/app_plugin/share_action.ts | 105 ++++ .../app_plugin/show_underlying_data.test.ts | 27 +- .../public/app_plugin/show_underlying_data.ts | 9 +- .../plugins/lens/public/app_plugin/types.ts | 33 +- .../editor_frame/state_helpers.ts | 11 +- .../lens/public/mocks/services_mock.tsx | 2 +- x-pack/plugins/lens/public/plugin.ts | 15 + .../init_middleware/load_initial.ts | 77 +++ .../public/state_management/lens_slice.ts | 21 + .../lens/public/state_management/types.ts | 16 +- x-pack/plugins/lens/public/utils.ts | 36 +- x-pack/plugins/lens/server/plugin.tsx | 7 + x-pack/plugins/lens/tsconfig.json | 1 + .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../apps/lens/group1/ad_hoc_data_view.ts | 29 + .../apps/lens/group1/smokescreen.ts | 21 - .../apps/lens/group1/text_based_languages.ts | 73 +++ .../test/functional/apps/lens/group2/index.ts | 1 + .../test/functional/apps/lens/group2/share.ts | 142 +++++ .../test/functional/page_objects/lens_page.ts | 98 +++- 41 files changed, 2026 insertions(+), 354 deletions(-) create mode 100644 x-pack/plugins/lens/common/locator/locator.test.ts create mode 100644 x-pack/plugins/lens/common/locator/locator.ts create mode 100644 x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content_lazy.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/share_action.ts create mode 100644 x-pack/test/functional/apps/lens/group2/share.ts diff --git a/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap b/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap index 19be58c7792b4b..6bbcd151687273 100644 --- a/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap +++ b/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap @@ -1,5 +1,81 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`shareContextMenuExtensions should render a custom panel title when provided 1`] = ` + + , + "id": 1, + "title": "Permalink", + }, + Object { + "content": , + "id": 2, + "title": "Embed Code", + }, + Object { + "content":
+ panel content +
, + "id": 3, + "title": "AAA panel", + }, + Object { + "content":
+ panel content +
, + "id": 4, + "title": "ZZZ panel", + }, + Object { + "id": 5, + "items": Array [ + Object { + "data-test-subj": "sharePanel-Embedcode", + "icon": "console", + "name": "Embed code", + "panel": 2, + }, + Object { + "data-test-subj": "sharePanel-Permalinks", + "disabled": false, + "icon": "link", + "name": "Permalinks", + "panel": 1, + }, + Object { + "data-test-subj": "sharePanel-ZZZpanel", + "name": "ZZZ panel", + "panel": 4, + }, + Object { + "data-test-subj": "sharePanel-AAApanel", + "name": "AAA panel", + "panel": 3, + }, + ], + "title": "Share this Custom object", + }, + ] + } + size="m" + /> +
+`; + exports[`shareContextMenuExtensions should sort ascending on sort order first and then ascending on name 1`] = ` `; +exports[`should disable the share URL when set 1`] = ` + + , + "id": 1, + "title": "Permalink", + }, + Object { + "content": , + "id": 2, + "title": "Embed Code", + }, + Object { + "id": 3, + "items": Array [ + Object { + "data-test-subj": "sharePanel-Embedcode", + "icon": "console", + "name": "Embed code", + "panel": 2, + }, + Object { + "data-test-subj": "sharePanel-Permalinks", + "disabled": true, + "icon": "link", + "name": "Permalinks", + "panel": 1, + }, + ], + "title": "Share this dashboard", + }, + ] + } + size="m" + /> + +`; + exports[`should only render permalink panel when there are no other panels 1`] = ` expect(component).toMatchSnapshot(); }); +test('should disable the share URL when set', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + describe('shareContextMenuExtensions', () => { const shareContextMenuItems: ShareMenuItem[] = [ { @@ -69,4 +74,15 @@ describe('shareContextMenuExtensions', () => { ); expect(component).toMatchSnapshot(); }); + + test('should render a custom panel title when provided', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/plugins/share/public/components/share_context_menu.tsx b/src/plugins/share/public/components/share_context_menu.tsx index c964737026b3b7..2d3ae3ac1b911c 100644 --- a/src/plugins/share/public/components/share_context_menu.tsx +++ b/src/plugins/share/public/components/share_context_menu.tsx @@ -25,6 +25,7 @@ export interface ShareContextMenuProps { objectId?: string; objectType: string; shareableUrl?: string; + shareableUrlForSavedObject?: string; shareMenuItems: ShareMenuItem[]; sharingData: any; onClose: () => void; @@ -33,6 +34,8 @@ export interface ShareContextMenuProps { showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; urlService: BrowserUrlService; snapshotShareWarning?: string; + objectTypeTitle?: string; + disabledShareUrl?: boolean; } export class ShareContextMenu extends Component { @@ -64,6 +67,7 @@ export class ShareContextMenu extends Component { objectId={this.props.objectId} objectType={this.props.objectType} shareableUrl={this.props.shareableUrl} + shareableUrlForSavedObject={this.props.shareableUrlForSavedObject} anonymousAccess={this.props.anonymousAccess} showPublicUrlSwitch={this.props.showPublicUrlSwitch} urlService={this.props.urlService} @@ -78,6 +82,7 @@ export class ShareContextMenu extends Component { icon: 'link', panel: permalinkPanel.id, sortOrder: 0, + disabled: Boolean(this.props.disabledShareUrl), }); panels.push(permalinkPanel); @@ -94,6 +99,7 @@ export class ShareContextMenu extends Component { objectId={this.props.objectId} objectType={this.props.objectType} shareableUrl={this.props.shareableUrl} + shareableUrlForSavedObject={this.props.shareableUrlForSavedObject} urlParamExtensions={this.props.embedUrlParamExtensions} anonymousAccess={this.props.anonymousAccess} showPublicUrlSwitch={this.props.showPublicUrlSwitch} @@ -131,7 +137,7 @@ export class ShareContextMenu extends Component { title: i18n.translate('share.contextMenuTitle', { defaultMessage: 'Share this {objectType}', values: { - objectType: this.props.objectType, + objectType: this.props.objectTypeTitle || this.props.objectType, }, }), items: menuItems diff --git a/src/plugins/share/public/components/url_panel_content.test.tsx b/src/plugins/share/public/components/url_panel_content.test.tsx index 969c5dffa864f5..f5d3ef0ac652cb 100644 --- a/src/plugins/share/public/components/url_panel_content.test.tsx +++ b/src/plugins/share/public/components/url_panel_content.test.tsx @@ -61,6 +61,22 @@ describe('share url panel content', () => { expect(component).toMatchSnapshot(); }); + test('should use custom savedObjectUrl if provided for saved object export', () => { + const component = shallow( + + ); + + act(() => { + component.find(EuiRadioGroup).prop('onChange')!(ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT); + }); + expect(component.find(EuiCopy).prop('textToCopy')).toEqual('socustomurl:id1#?_g='); + }); + test('should hide short url section when allowShortUrl is false', () => { const component = shallow( diff --git a/src/plugins/share/public/components/url_panel_content.tsx b/src/plugins/share/public/components/url_panel_content.tsx index 32441ab2945eba..fb2de6811b4d5f 100644 --- a/src/plugins/share/public/components/url_panel_content.tsx +++ b/src/plugins/share/public/components/url_panel_content.tsx @@ -42,6 +42,7 @@ export interface UrlPanelContentProps { objectId?: string; objectType: string; shareableUrl?: string; + shareableUrlForSavedObject?: string; urlParamExtensions?: UrlParamExtension[]; anonymousAccess?: AnonymousAccessServiceContract; showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; @@ -242,7 +243,7 @@ export class UrlPanelContent extends Component { return; } - const url = this.getSnapshotUrl(); + const url = this.getSnapshotUrl(true); const parsedUrl = parseUrl(url); if (!parsedUrl || !parsedUrl.hash) { @@ -269,8 +270,14 @@ export class UrlPanelContent extends Component { return this.updateUrlParams(formattedUrl); }; - private getSnapshotUrl = () => { - const url = this.props.shareableUrl || window.location.href; + private getSnapshotUrl = (forSavedObject?: boolean) => { + let url = ''; + if (forSavedObject && this.props.shareableUrlForSavedObject) { + url = this.props.shareableUrlForSavedObject; + } + if (!url) { + url = this.props.shareableUrl || window.location.href; + } return this.updateUrlParams(url); }; diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index a393d4aba60336..d63ceaf115e10d 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -69,6 +69,7 @@ export class ShareMenuManager { sharingData, menuItems, shareableUrl, + shareableUrlForSavedObject, embedUrlParamExtensions, theme, showPublicUrlSwitch, @@ -76,6 +77,8 @@ export class ShareMenuManager { anonymousAccess, snapshotShareWarning, onClose, + objectTypeTitle, + disabledShareUrl, }: ShowShareMenuOptions & { menuItems: ShareMenuItem[]; urlService: BrowserUrlService; @@ -107,15 +110,18 @@ export class ShareMenuManager { allowShortUrl={allowShortUrl} objectId={objectId} objectType={objectType} + objectTypeTitle={objectTypeTitle} shareMenuItems={menuItems} sharingData={sharingData} shareableUrl={shareableUrl} + shareableUrlForSavedObject={shareableUrlForSavedObject} onClose={onClose} embedUrlParamExtensions={embedUrlParamExtensions} anonymousAccess={anonymousAccess} showPublicUrlSwitch={showPublicUrlSwitch} urlService={urlService} snapshotShareWarning={snapshotShareWarning} + disabledShareUrl={disabledShareUrl} /> diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index b1cd995a5ff84d..bbf857e9847aad 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -41,10 +41,12 @@ export interface ShareContext { * If not set it will default to `window.location.href` */ shareableUrl: string; + shareableUrlForSavedObject?: string; sharingData: { [key: string]: unknown }; isDirty: boolean; onClose: () => void; showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; + disabledShareUrl?: boolean; } /** @@ -99,4 +101,5 @@ export interface ShowShareMenuOptions extends Omit { embedUrlParamExtensions?: UrlParamExtension[]; snapshotShareWarning?: string; onClose?: () => void; + objectTypeTitle?: string; } diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index cfca7391202e52..9de8ba0d569f0c 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -461,6 +461,14 @@ class BrowserService extends FtrService { await this.driver.switchTo().window(tabs[tabIndex]); } + /** + * Opens a blank new tab. + * @return {Promise} + */ + public async openNewTab() { + await this.driver.switchTo().newWindow('tab'); + } + /** * Sets a value in local storage for the focused window/frame. * diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 329b1cb7d182bb..1495410cdb14c6 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -6,7 +6,8 @@ */ import rison from '@kbn/rison'; -import type { TimeRange } from '@kbn/data-plugin/common/query'; +import type { RefreshInterval, TimeRange } from '@kbn/data-plugin/common/query'; +import type { Filter } from '@kbn/es-query'; export const PLUGIN_ID = 'lens'; export const APP_ID = 'lens'; @@ -53,16 +54,35 @@ export function getBasePath() { const GLOBAL_RISON_STATE_PARAM = '_g'; -export function getEditPath(id: string | undefined, timeRange?: TimeRange) { - let timeParam = ''; +export function getEditPath( + id: string | undefined, + timeRange?: TimeRange, + filters?: Filter[], + refreshInterval?: RefreshInterval +) { + const searchArgs: { + time?: TimeRange; + filters?: Filter[]; + refreshInterval?: RefreshInterval; + } = {}; if (timeRange) { - timeParam = `?${GLOBAL_RISON_STATE_PARAM}=${rison.encode({ time: timeRange })}`; + searchArgs.time = timeRange; } + if (filters) { + searchArgs.filters = filters; + } + if (refreshInterval) { + searchArgs.refreshInterval = refreshInterval; + } + + const searchParam = Object.keys(searchArgs).length + ? `?${GLOBAL_RISON_STATE_PARAM}=${rison.encode(searchArgs)}` + : ''; return id - ? `#/edit/${encodeURIComponent(id)}${timeParam}` - : `#/${LENS_EDIT_BY_VALUE}${timeParam}`; + ? `#/edit/${encodeURIComponent(id)}${searchParam}` + : `#/${LENS_EDIT_BY_VALUE}${searchParam}`; } export function getFullPath(id?: string) { diff --git a/x-pack/plugins/lens/common/helpers.test.ts b/x-pack/plugins/lens/common/helpers.test.ts index 1bf3ec49a4780c..bfc490fd1e9777 100644 --- a/x-pack/plugins/lens/common/helpers.test.ts +++ b/x-pack/plugins/lens/common/helpers.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { FilterStateStore } from '@kbn/es-query'; import { getEditPath } from './constants'; describe('getEditPath', function () { @@ -27,4 +28,76 @@ describe('getEditPath', function () { '#/edit/12345?_g=(time:(from:now-15m,to:now))' ); }); + + it('should return value when filters are given', () => { + expect( + getEditPath(undefined, undefined, [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ]) + ).toEqual( + "#/edit_by_value?_g=(filters:!(('$state':(store:appState),meta:(alias:foo,disabled:!f,negate:!f)),('$state':(store:globalState),meta:(alias:bar,disabled:!f,negate:!f))))" + ); + }); + + it('should return value when refresh interval is given', () => { + expect(getEditPath(undefined, undefined, undefined, { pause: false, value: 10 })).toEqual( + '#/edit_by_value?_g=(refreshInterval:(pause:!f,value:10))' + ); + }); + + it('should return value when time, filters and refresh interval are given', () => { + expect( + getEditPath( + undefined, + { from: 'now-15m', to: 'now' }, + [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + { + pause: false, + value: 10, + } + ) + ).toEqual( + "#/edit_by_value?_g=(filters:!(('$state':(store:appState),meta:(alias:foo,disabled:!f,negate:!f)),('$state':(store:globalState),meta:(alias:bar,disabled:!f,negate:!f))),refreshInterval:(pause:!f,value:10),time:(from:now-15m,to:now))" + ); + }); }); diff --git a/x-pack/plugins/lens/common/locator/locator.test.ts b/x-pack/plugins/lens/common/locator/locator.test.ts new file mode 100644 index 00000000000000..b91f3d6a0412fb --- /dev/null +++ b/x-pack/plugins/lens/common/locator/locator.test.ts @@ -0,0 +1,172 @@ +/* + * 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 { FilterStateStore } from '@kbn/es-query'; +import { LensAppLocatorDefinition, type LensAppLocatorParams } from './locator'; + +const savedObjectId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d'; + +const setup = async () => { + const locator = new LensAppLocatorDefinition(); + + return { + locator, + }; +}; + +const lensShareableState: LensAppLocatorParams = { + visualization: { activeId: 'bar_chart', state: {} }, + activeDatasourceId: 'xxxxx', + datasourceStates: { formBased: { state: {} } }, + references: [], +}; + +function getParams(path: string, param: string) { + // just make it a valid URL + // in order to extract the search params + const basepathTest = 'http://localhost/'; + const url = new URL(path, basepathTest); + return url.searchParams.get(param); +} + +describe('Lens url generator', () => { + test('can create a link to Lens with no state and no saved viz', async () => { + const { locator } = await setup(); + const { app, path, state } = await locator.getLocation({}); + + expect(app).toBe('lens'); + expect(path).toBeDefined(); + expect(state.payload).toBeDefined(); + expect(Object.keys(state.payload)).toHaveLength(0); + }); + + test('can create a link to a saved viz in Lens', async () => { + const { locator } = await setup(); + const { path } = await locator.getLocation({ savedObjectId }); + + expect(path.includes(`#/edit/${savedObjectId}`)).toBe(true); + }); + + test('can specify specific time range', async () => { + const { locator } = await setup(); + const { path, state } = await locator.getLocation({ + resolvedDateRange: { fromDate: 'now', toDate: 'now-15m', mode: 'relative' }, + }); + expect(getParams(path, '_g')).toEqual('(time:(from:now,to:now-15m))'); + expect(state.payload.resolvedDateRange).toBeDefined(); + }); + + test('can specify query', async () => { + const { locator } = await setup(); + const { path, state } = await locator.getLocation({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + expect(getParams(path, '_g')).toEqual('()'); + expect(state.payload).toEqual({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + }); + + test('can specify local and global filters', async () => { + const { locator } = await setup(); + const { path, state } = await locator.getLocation({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + expect(getParams(path, '_g')).toEqual( + "(filters:!(('$state':(store:appState),meta:(alias:foo,disabled:!f,negate:!f))))" + ); + expect(state.payload).toEqual({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + }); + + test('can specify a search session id', async () => { + const { locator } = await setup(); + const { state } = await locator.getLocation({ + searchSessionId: '__test__', + }); + + expect(state.payload).toEqual({ searchSessionId: '__test__' }); + }); + + test('should return state if all params are passed correctly', async () => { + const { locator } = await setup(); + const { state } = await locator.getLocation(lensShareableState); + + expect(Object.keys(state.payload)).toHaveLength(Object.keys(lensShareableState).length); + }); + + test('should return no state for partial/missing state params', async () => { + const { locator } = await setup(); + const { state } = await locator.getLocation({ ...lensShareableState, references: undefined }); + + expect(Object.keys(state.payload)).toHaveLength(0); + }); + + test('should create data view when dataViewSpec is used', async () => { + const dataViewSpecMock = { + id: 'mock-id', + title: 'mock-title', + timeFieldName: 'mock-time-field-name', + }; + const { locator } = await setup(); + const { state } = await locator.getLocation({ + ...lensShareableState, + dataViewSpecs: [dataViewSpecMock], + }); + + expect(state.payload.dataViewSpecs).toEqual([dataViewSpecMock]); + }); +}); diff --git a/x-pack/plugins/lens/common/locator/locator.ts b/x-pack/plugins/lens/common/locator/locator.ts new file mode 100644 index 00000000000000..ea0e54136ffc99 --- /dev/null +++ b/x-pack/plugins/lens/common/locator/locator.ts @@ -0,0 +1,215 @@ +/* + * 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 rison from '@kbn/rison'; +import type { SerializableRecord } from '@kbn/utility-types'; +import type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public'; +import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/common'; +import type { Filter, Query } from '@kbn/es-query'; +import type { DataViewSpec, SavedQuery } from '@kbn/data-plugin/common'; +import { SavedObjectReference } from '@kbn/core-saved-objects-common'; +import type { DateRange } from '../types'; + +export const LENS_APP_LOCATOR = 'LENS_APP_LOCATOR'; +export const LENS_SHARE_STATE_ACTION = 'LENS_SHARE_STATE_ACTION'; + +interface LensShareableState { + /** + * Optionally apply filters. + */ + filters?: Filter[]; + + /** + * Optionally set a query. + */ + query?: Query; + + /** + * Optionally set the date range in the date picker. + */ + resolvedDateRange?: DateRange & SerializableRecord; + + /** + * Optionally set the id of the used saved query + */ + savedQuery?: SavedQuery & SerializableRecord; + + /** + * Set the visualization configuration + */ + visualization: { activeId: string | null; state: unknown } & SerializableRecord; + + /** + * Set the active datasource used + */ + activeDatasourceId?: string; + + /** + * Set the datasources configurations + */ + datasourceStates: Record & SerializableRecord; + + /** + * Background search session id + */ + searchSessionId?: string; + + /** + * Set the references used in the Lens state + */ + references: Array; + + /** + * Pass adHoc dataViews specs used in the Lens state + */ + dataViewSpecs?: DataViewSpec[]; +} + +export interface LensAppLocatorParams extends SerializableRecord { + /** + * Optionally set saved object ID. + */ + savedObjectId?: string; + + /** + * Background search session id + */ + searchSessionId?: string; + + /** + * Optionally apply filters. + */ + filters?: Filter[]; + + /** + * Optionally set a query. + */ + query?: Query; + + /** + * Optionally set the date range in the date picker. + */ + resolvedDateRange?: DateRange & SerializableRecord; + + /** + * Optionally set the id of the used saved query + */ + savedQuery?: SavedQuery & SerializableRecord; + + /** + * In case of no savedObjectId passed, the properties above have to be passed + */ + + /** + * Set the active datasource used + */ + activeDatasourceId?: string | null; + + /** + * Set the visualization configuration + */ + visualization?: { activeId: string | null; state: unknown } & SerializableRecord; + + /** + * Set the datasources configurations + */ + datasourceStates?: Record & SerializableRecord; + + /** + * Set the references used in the Lens state + */ + references?: Array; + + /** + * Pass adHoc dataViews specs used in the Lens state + */ + dataViewSpecs?: DataViewSpec[]; +} + +export type LensAppLocator = LocatorPublic; + +/** + * Location state of scoped history (history instance of Kibana Platform application service) + */ +export interface MainHistoryLocationState { + type: typeof LENS_SHARE_STATE_ACTION; + payload: + | LensShareableState + | Omit< + LensShareableState, + 'activeDatasourceId' | 'visualization' | 'datasourceStates' | 'references' + >; +} + +function getStateFromParams(params: LensAppLocatorParams): MainHistoryLocationState['payload'] { + if (params.savedObjectId) { + return {}; + } + + // return no state for malformed state? + if ( + !( + params.activeDatasourceId && + params.datasourceStates && + params.visualization && + params.references + ) + ) { + return {}; + } + const outputState: LensShareableState = { + activeDatasourceId: params.activeDatasourceId!, + visualization: params.visualization!, + datasourceStates: Object.fromEntries( + Object.entries(params.datasourceStates!).map(([id, { state }]) => [id, state]) + ) as Record & SerializableRecord, + references: params.references!, + }; + if (params.dataViewSpecs) { + outputState.dataViewSpecs = params.dataViewSpecs; + } + return outputState; +} + +export class LensAppLocatorDefinition implements LocatorDefinition { + public readonly id = LENS_APP_LOCATOR; + + public readonly getLocation = async (params: LensAppLocatorParams) => { + const { filters, query, savedObjectId, resolvedDateRange, searchSessionId } = params; + const appState = getStateFromParams(params); + const queryState: GlobalQueryStateFromUrl = {}; + const { isFilterPinned } = await import('@kbn/es-query'); + + if (query) { + appState.query = query; + } + if (resolvedDateRange) { + appState.resolvedDateRange = resolvedDateRange; + queryState.time = { from: resolvedDateRange.fromDate, to: resolvedDateRange.toDate }; + } + if (filters?.length) { + appState.filters = filters; + queryState.filters = filters?.filter((f) => !isFilterPinned(f)); + } + + const savedObjectPath = savedObjectId ? `/edit/${encodeURIComponent(savedObjectId)}` : ''; + const basepath = `${window.location.origin}${window.location.pathname}`; + const url = new URL(basepath); + url.hash = savedObjectPath; + url.searchParams.append('_g', rison.encodeUnknown(queryState) || ''); + + if (searchSessionId) { + appState.searchSessionId = searchSessionId; + } + + return { + app: 'lens', + path: url.href.replace(basepath, ''), + state: { type: LENS_SHARE_STATE_ACTION, payload: appState }, + }; + }; +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 648fd61203943a..fbb82b11c0012b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -899,19 +899,19 @@ describe('Lens App', () => { }); }); - describe('download button', () => { - function getButton(inst: ReactWrapper): TopNavMenuData { + describe('share button', () => { + function getShareButton(inst: ReactWrapper): TopNavMenuData { return ( inst.find('[data-test-subj="lnsApp_topNav"]').prop('config') as TopNavMenuData[] - ).find((button) => button.testId === 'lnsApp_downloadCSVButton')!; + ).find((button) => button.testId === 'lnsApp_shareButton')!; } it('should be disabled when no data is available', async () => { const { instance } = await mountWith({ preloadedState: { isSaveable: true } }); - expect(getButton(instance).disableButton).toEqual(true); + expect(getShareButton(instance).disableButton).toEqual(true); }); - it('should disable download when not saveable', async () => { + it('should not disable share when not saveable', async () => { const { instance } = await mountWith({ preloadedState: { isSaveable: false, @@ -919,7 +919,7 @@ describe('Lens App', () => { }, }); - expect(getButton(instance).disableButton).toEqual(true); + expect(getShareButton(instance).disableButton).toEqual(false); }); it('should still be enabled even if the user is missing save permissions', async () => { @@ -928,7 +928,27 @@ describe('Lens App', () => { ...services.application, capabilities: { ...services.application.capabilities, - visualize: { save: false, saveQuery: false, show: true }, + visualize: { save: false, saveQuery: false, show: true, createShortUrl: true }, + }, + }; + + const { instance } = await mountWith({ + services, + preloadedState: { + isSaveable: true, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }, + }); + expect(getShareButton(instance).disableButton).toEqual(false); + }); + + it('should still be enabled even if the user is missing shortUrl permissions', async () => { + const services = makeDefaultServicesForApp(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { save: true, saveQuery: false, show: true, createShortUrl: false }, }, }; @@ -939,7 +959,27 @@ describe('Lens App', () => { activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, }, }); - expect(getButton(instance).disableButton).toEqual(false); + expect(getShareButton(instance).disableButton).toEqual(false); + }); + + it('should be disabled if the user is missing shortUrl permissions and visualization is not saveable', async () => { + const services = makeDefaultServicesForApp(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { save: false, saveQuery: false, show: true, createShortUrl: false }, + }, + }; + + const { instance } = await mountWith({ + services, + preloadedState: { + isSaveable: false, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }, + }); + expect(getShareButton(instance).disableButton).toEqual(true); }); }); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 81cc45e0af432c..6c70000ed4e0c1 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -12,6 +12,7 @@ import { EuiBreadcrumb, EuiConfirmModal } from '@elastic/eui'; import { useExecutionContext, useKibana } from '@kbn/kibana-react-plugin/public'; import { OnSaveProps } from '@kbn/saved-objects-plugin/public'; import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; +import type { LensAppLocatorParams } from '../../common/locator/locator'; import { LensAppProps, LensAppServices } from './types'; import { LensTopNavMenu } from './lens_top_nav'; import { LensByReferenceInput } from '../embeddable'; @@ -32,7 +33,10 @@ import { SaveModalContainer, runSaveLensVisualization } from './save_modal_conta import { LensInspector } from '../lens_inspector_service'; import { getEditPath } from '../../common'; import { isLensEqual } from './lens_document_equality'; -import { IndexPatternServiceAPI, createIndexPatternService } from '../data_views_service/service'; +import { + type IndexPatternServiceAPI, + createIndexPatternService, +} from '../data_views_service/service'; import { replaceIndexpattern } from '../state_management/lens_slice'; export type SaveProps = Omit & { @@ -77,6 +81,8 @@ export function App({ executionContext, // Temporarily required until the 'by value' paradigm is default. dashboardFeatureFlag, + locator, + share, } = lensAppServices; const saveAndExit = useRef<() => void>(); @@ -109,6 +115,8 @@ export function App({ selectSavedObjectFormat(state, selectorDependencies) ); + const shortUrls = useMemo(() => share?.url.shortUrls.get(null), [share]); + // Used to show a popover that guides the user towards changing the date range when no data is available. const [indicateNoData, setIndicateNoData] = useState(false); const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); @@ -427,6 +435,31 @@ export function App({ }; }, []); + // remember latest URL based on the configuration + // url_panel_content has a similar logic + const shareURLCache = useRef({ params: '', url: '' }); + + const shortUrlService = useCallback( + async (params: LensAppLocatorParams) => { + const cacheKey = JSON.stringify(params); + if (shareURLCache.current.params === cacheKey) { + return shareURLCache.current.url; + } + if (locator && shortUrls) { + // This is a stripped down version of what the share URL plugin is doing + const relativeUrl = await shortUrls.create({ locator, params }); + const absoluteShortUrl = application.getUrlForApp('', { + path: `/r/s/${relativeUrl.data.slug}`, + absolute: true, + }); + shareURLCache.current = { params: cacheKey, url: absoluteShortUrl }; + return absoluteShortUrl; + } + return ''; + }, + [locator, application, shortUrls] + ); + const returnToOriginSwitchLabelForContext = initialContext && 'isEmbeddable' in initialContext && @@ -457,6 +490,14 @@ export function App({ title={persistedDoc?.title} lensInspector={lensInspector} currentDoc={currentDoc} + isCurrentStateDirty={ + !isLensEqual( + persistedDoc, + lastKnownDoc, + data.query.filterManager.inject.bind(data.query.filterManager), + datasourceMap + ) + } goBackToOriginatingApp={goBackToOriginatingApp} contextOriginatingApp={contextOriginatingApp} initialContextIsEmbedded={initialContextIsEmbedded} @@ -465,6 +506,7 @@ export function App({ theme$={theme$} indexPatternService={indexPatternService} onTextBasedSavedAndExit={onTextBasedSavedAndExit} + shortUrlService={shortUrlService} /> {getLegacyUrlConflictCallout()} {(!isLoading || persistedDoc) && ( diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx new file mode 100644 index 00000000000000..59d76f78123fcc --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiButton, EuiForm, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export interface DownloadPanelContentProps { + isDisabled: boolean; + onClick: () => void; + warnings?: React.ReactNode[]; +} + +export function DownloadPanelContent({ + isDisabled, + onClick, + warnings = [], +}: DownloadPanelContentProps) { + return ( + + +

+ +

+ {warnings.map((warning, i) => ( +

{warning}

+ ))} +
+ + + + +
+ ); +} diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content_lazy.tsx b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content_lazy.tsx new file mode 100644 index 00000000000000..dded4f4768a16e --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_panel_content_lazy.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import * as React from 'react'; +import { FC, lazy, Suspense } from 'react'; +import type { DownloadPanelContentProps } from './csv_download_panel_content'; + +const LazyComponent = lazy(() => + import('./csv_download_panel_content').then(({ DownloadPanelContent }) => ({ + default: DownloadPanelContent, + })) +); + +export const PanelSpinner: React.FC = (props) => { + return ( + <> + + + + + + + + + ); +}; + +export const DownloadPanelContent: FC> = (props) => { + return ( + }> + + + ); +}; diff --git a/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx new file mode 100644 index 00000000000000..bdcb5e5e74edd3 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/csv_download_provider/csv_download_provider.tsx @@ -0,0 +1,155 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import { tableHasFormulas } from '@kbn/data-plugin/common'; +import { downloadMultipleAs, ShareContext, ShareMenuProvider } from '@kbn/share-plugin/public'; +import { exporters } from '@kbn/data-plugin/public'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { FormatFactory } from '../../../common'; +import { DownloadPanelContent } from './csv_download_panel_content_lazy'; +import { TableInspectorAdapter } from '../../editor_frame_service/types'; + +declare global { + interface Window { + /** + * Debug setting to test CSV download + */ + ELASTIC_LENS_CSV_DOWNLOAD_DEBUG?: boolean; + ELASTIC_LENS_CSV_CONTENT?: Record; + } +} + +async function downloadCSVs({ + activeData, + title, + formatFactory, + uiSettings, +}: { + title: string; + activeData: TableInspectorAdapter; + formatFactory: FormatFactory; + uiSettings: IUiSettingsClient; +}) { + if (!activeData) { + if (window.ELASTIC_LENS_CSV_DOWNLOAD_DEBUG) { + window.ELASTIC_LENS_CSV_CONTENT = undefined; + } + return; + } + const datatables = Object.values(activeData); + const content = datatables.reduce>( + (memo, datatable, i) => { + // skip empty datatables + if (datatable) { + const postFix = datatables.length > 1 ? `-${i + 1}` : ''; + + memo[`${title}${postFix}.csv`] = { + content: exporters.datatableToCSV(datatable, { + csvSeparator: uiSettings.get('csv:separator', ','), + quoteValues: uiSettings.get('csv:quoteValues', true), + formatFactory, + escapeFormulaValues: false, + }), + type: exporters.CSV_MIME_TYPE, + }; + } + return memo; + }, + {} + ); + if (window.ELASTIC_LENS_CSV_DOWNLOAD_DEBUG) { + window.ELASTIC_LENS_CSV_CONTENT = content; + } + if (content) { + downloadMultipleAs(content); + } +} + +function getWarnings(activeData: TableInspectorAdapter) { + const messages = []; + if (activeData) { + const datatables = Object.values(activeData); + const formulaDetected = datatables.some((datatable) => { + return tableHasFormulas(datatable.columns, datatable.rows); + }); + if (formulaDetected) { + messages.push( + i18n.translate('xpack.lens.app.downloadButtonFormulasWarning', { + defaultMessage: + 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', + }) + ); + } + } + return messages; +} + +interface DownloadPanelShareOpts { + uiSettings: IUiSettingsClient; + formatFactoryFn: () => FormatFactory; +} + +export const downloadCsvShareProvider = ({ + uiSettings, + formatFactoryFn, +}: DownloadPanelShareOpts): ShareMenuProvider => { + const getShareMenuItems = ({ objectType, sharingData, onClose }: ShareContext) => { + if ('lens_visualization' !== objectType) { + return []; + } + + const { title, activeData, csvEnabled } = sharingData as { + title: string; + activeData: TableInspectorAdapter; + csvEnabled: boolean; + }; + + const panelTitle = i18n.translate( + 'xpack.lens.reporting.shareContextMenu.csvReportsButtonLabel', + { + defaultMessage: 'CSV Download', + } + ); + + return [ + { + shareMenuItem: { + name: panelTitle, + icon: 'document', + disabled: !csvEnabled, + sortOrder: 1, + }, + panel: { + id: 'csvDownloadPanel', + title: panelTitle, + content: ( + { + await downloadCSVs({ + title, + formatFactory: formatFactoryFn(), + activeData, + uiSettings, + }); + onClose?.(); + }} + /> + ), + }, + }, + ]; + }; + + return { + id: 'csvDownload', + getShareMenuItems, + }; +}; diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 2b94c0bf20c6ea..4a498cbb23266a 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -11,19 +11,12 @@ import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { isOfAggregateQueryType } from '@kbn/es-query'; import { useStore } from 'react-redux'; import { TopNavMenuData } from '@kbn/navigation-plugin/public'; -import { downloadMultipleAs } from '@kbn/share-plugin/public'; -import { tableHasFormulas } from '@kbn/data-plugin/common'; -import { exporters, getEsQueryConfig } from '@kbn/data-plugin/public'; +import { getEsQueryConfig } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; import { ENABLE_SQL } from '../../common'; -import { - LensAppServices, - LensTopNavActions, - LensTopNavMenuProps, - LensTopNavTooltips, -} from './types'; +import { LensAppServices, LensTopNavActions, LensTopNavMenuProps } from './types'; import { toggleSettingsMenuOpen } from './settings_menu'; import { setState, @@ -42,16 +35,72 @@ import { import { combineQueryAndFilters, getLayerMetaInfo } from './show_underlying_data'; import { changeIndexPattern } from '../state_management/lens_slice'; import { LensByReferenceInput } from '../embeddable'; +import { getShareURL } from './share_action'; -function getLensTopNavConfig(options: { +function getSaveButtonMeta({ + contextFromEmbeddable, + showSaveAndReturn, + showReplaceInDashboard, + showReplaceInCanvas, +}: { + contextFromEmbeddable: boolean | undefined; showSaveAndReturn: boolean; - enableExportToCSV: boolean; - showOpenInDiscover?: boolean; - showCancel: boolean; + showReplaceInDashboard: boolean; + showReplaceInCanvas: boolean; +}) { + if (showSaveAndReturn) { + return { + label: contextFromEmbeddable + ? i18n.translate('xpack.lens.app.saveAndReplace', { + defaultMessage: 'Save and replace', + }) + : i18n.translate('xpack.lens.app.saveAndReturn', { + defaultMessage: 'Save and return', + }), + emphasize: true, + iconType: contextFromEmbeddable ? 'save' : 'checkInCircleFilled', + testId: 'lnsApp_saveAndReturnButton', + description: i18n.translate('xpack.lens.app.saveAndReturnButtonAriaLabel', { + defaultMessage: 'Save the current lens visualization and return to the last app', + }), + }; + } + + if (showReplaceInDashboard) { + return { + label: i18n.translate('xpack.lens.app.replaceInDashboard', { + defaultMessage: 'Replace in dashboard', + }), + emphasize: true, + iconType: 'merge', + testId: 'lnsApp_replaceInDashboardButton', + description: i18n.translate('xpack.lens.app.replaceInDashboardButtonAriaLabel', { + defaultMessage: + 'Replace legacy visualization with lens visualization and return to the dashboard', + }), + }; + } + + if (showReplaceInCanvas) { + return { + label: i18n.translate('xpack.lens.app.replaceInCanvas', { + defaultMessage: 'Replace in canvas', + }), + emphasize: true, + iconType: 'merge', + testId: 'lnsApp_replaceInCanvasButton', + description: i18n.translate('xpack.lens.app.replaceInCanvasButtonAriaLabel', { + defaultMessage: + 'Replace legacy visualization with lens visualization and return to the canvas', + }), + }; + } +} + +function getLensTopNavConfig(options: { isByValueMode: boolean; allowByValue: boolean; actions: LensTopNavActions; - tooltips: LensTopNavTooltips; savingToLibraryPermitted: boolean; savingToDashboardPermitted: boolean; contextOriginatingApp?: string; @@ -62,34 +111,28 @@ function getLensTopNavConfig(options: { }): TopNavMenuData[] { const { actions, - showCancel, allowByValue, - enableExportToCSV, - showOpenInDiscover, - showSaveAndReturn, savingToLibraryPermitted, savingToDashboardPermitted, - tooltips, contextOriginatingApp, - isSaveable, showReplaceInDashboard, showReplaceInCanvas, contextFromEmbeddable, + isByValueMode, } = options; const topNavMenu: TopNavMenuData[] = []; + const showSaveAndReturn = actions.saveAndReturn.visible; + const enableSaveButton = savingToLibraryPermitted || - (allowByValue && - savingToDashboardPermitted && - !options.isByValueMode && - !options.showSaveAndReturn); + (allowByValue && savingToDashboardPermitted && !isByValueMode && !showSaveAndReturn); - const saveButtonLabel = options.isByValueMode + const saveButtonLabel = isByValueMode ? i18n.translate('xpack.lens.app.addToLibrary', { defaultMessage: 'Save to library', }) - : options.showSaveAndReturn + : actions.saveAndReturn.visible ? i18n.translate('xpack.lens.app.saveAs', { defaultMessage: 'Save as', }) @@ -97,38 +140,38 @@ function getLensTopNavConfig(options: { defaultMessage: 'Save', }); - if (contextOriginatingApp && !showCancel) { + if (contextOriginatingApp && !actions.cancel.visible) { topNavMenu.push({ label: i18n.translate('xpack.lens.app.goBackLabel', { defaultMessage: `Go back to {contextOriginatingApp}`, values: { contextOriginatingApp }, }), - run: actions.goBack, + run: actions.goBack.execute, className: 'lnsNavItem__withDivider', testId: 'lnsApp_goBackToAppButton', description: i18n.translate('xpack.lens.app.goBackLabel', { defaultMessage: `Go back to {contextOriginatingApp}`, values: { contextOriginatingApp }, }), - disableButton: false, + disableButton: !actions.goBack.enabled, }); } - if (showOpenInDiscover) { + if (actions.getUnderlyingDataUrl.visible) { const exploreDataInDiscoverLabel = i18n.translate('xpack.lens.app.exploreDataInDiscover', { defaultMessage: 'Explore data in Discover', }); topNavMenu.push({ label: exploreDataInDiscoverLabel, - run: () => {}, + run: actions.getUnderlyingDataUrl.execute, testId: 'lnsApp_openInDiscover', className: 'lnsNavItem__withDivider', description: exploreDataInDiscoverLabel, - disableButton: Boolean(tooltips.showUnderlyingDataWarning()), - tooltip: tooltips.showUnderlyingDataWarning, + disableButton: !actions.getUnderlyingDataUrl.enabled, + tooltip: actions.getUnderlyingDataUrl.tooltip, target: '_blank', - href: actions.getUnderlyingDataUrl(), + href: actions.getUnderlyingDataUrl.getLink?.(), }); } @@ -136,7 +179,7 @@ function getLensTopNavConfig(options: { label: i18n.translate('xpack.lens.app.inspect', { defaultMessage: 'Inspect', }), - run: actions.inspect, + run: actions.inspect.execute, testId: 'lnsApp_inspectButton', description: i18n.translate('xpack.lens.app.inspectAriaLabel', { defaultMessage: 'inspect', @@ -144,24 +187,26 @@ function getLensTopNavConfig(options: { disableButton: false, }); - topNavMenu.push({ - label: i18n.translate('xpack.lens.app.downloadCSV', { - defaultMessage: 'Download as CSV', - }), - run: actions.exportToCSV, - testId: 'lnsApp_downloadCSVButton', - description: i18n.translate('xpack.lens.app.downloadButtonAriaLabel', { - defaultMessage: 'Download the data as CSV file', - }), - disableButton: !enableExportToCSV, - tooltip: tooltips.showExportWarning, - }); + if (actions.share.visible) { + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.shareTitle', { + defaultMessage: 'Share', + }), + run: actions.share.execute, + testId: 'lnsApp_shareButton', + description: i18n.translate('xpack.lens.app.shareTitleAria', { + defaultMessage: 'Share visualization', + }), + disableButton: !actions.share.enabled, + tooltip: actions.share.tooltip, + }); + } topNavMenu.push({ label: i18n.translate('xpack.lens.app.settings', { defaultMessage: 'Settings', }), - run: actions.openSettings, + run: actions.openSettings.execute, className: 'lnsNavItem__withDivider', testId: 'lnsApp_settingsButton', description: i18n.translate('xpack.lens.app.settingsAriaLabel', { @@ -169,12 +214,12 @@ function getLensTopNavConfig(options: { }), }); - if (showCancel) { + if (actions.cancel.visible) { topNavMenu.push({ label: i18n.translate('xpack.lens.app.cancel', { defaultMessage: 'Cancel', }), - run: actions.cancel, + run: actions.cancel.execute, testId: 'lnsApp_cancelButton', description: i18n.translate('xpack.lens.app.cancelButtonAriaLabel', { defaultMessage: 'Return to the last app without saving changes', @@ -188,7 +233,7 @@ function getLensTopNavConfig(options: { ? 'save' : undefined, emphasize: showReplaceInDashboard || showReplaceInCanvas ? false : !showSaveAndReturn, - run: actions.showSaveModal, + run: actions.showSaveModal.execute, testId: 'lnsApp_saveButton', description: i18n.translate('xpack.lens.app.saveButtonAriaLabel', { defaultMessage: 'Save the current lens visualization', @@ -196,59 +241,21 @@ function getLensTopNavConfig(options: { disableButton: !enableSaveButton, }); - if (showSaveAndReturn) { - topNavMenu.push({ - label: contextFromEmbeddable - ? i18n.translate('xpack.lens.app.saveAndReplace', { - defaultMessage: 'Save and replace', - }) - : i18n.translate('xpack.lens.app.saveAndReturn', { - defaultMessage: 'Save and return', - }), - emphasize: true, - iconType: contextFromEmbeddable ? 'save' : 'checkInCircleFilled', - run: actions.saveAndReturn, - testId: 'lnsApp_saveAndReturnButton', - disableButton: !isSaveable, - description: i18n.translate('xpack.lens.app.saveAndReturnButtonAriaLabel', { - defaultMessage: 'Save the current lens visualization and return to the last app', - }), - }); - } + const saveButtonMeta = getSaveButtonMeta({ + showSaveAndReturn, + showReplaceInDashboard, + showReplaceInCanvas, + contextFromEmbeddable, + }); - if (showReplaceInDashboard) { + if (saveButtonMeta) { topNavMenu.push({ - label: i18n.translate('xpack.lens.app.replaceInDashboard', { - defaultMessage: 'Replace in dashboard', - }), - emphasize: true, - iconType: 'merge', - run: actions.saveAndReturn, - testId: 'lnsApp_replaceInDashboardButton', - disableButton: !isSaveable, - description: i18n.translate('xpack.lens.app.replaceInDashboardButtonAriaLabel', { - defaultMessage: - 'Replace legacy visualization with lens visualization and return to the dashboard', - }), + ...saveButtonMeta, + run: actions.saveAndReturn.execute, + disableButton: !actions.saveAndReturn.enabled, }); } - if (showReplaceInCanvas) { - topNavMenu.push({ - label: i18n.translate('xpack.lens.app.replaceInCanvas', { - defaultMessage: 'Replace in canvas', - }), - emphasize: true, - iconType: 'merge', - run: actions.saveAndReturn, - testId: 'lnsApp_replaceInCanvasButton', - disableButton: !isSaveable, - description: i18n.translate('xpack.lens.app.replaceInCanvasButtonAriaLabel', { - defaultMessage: - 'Replace legacy visualization with lens visualization and return to the canvas', - }), - }); - } return topNavMenu; } @@ -274,10 +281,11 @@ export const LensTopNavMenu = ({ indexPatternService, currentDoc, onTextBasedSavedAndExit, + shortUrlService, + isCurrentStateDirty, }: LensTopNavMenuProps) => { const { data, - fieldFormats, navigation, uiSettings, application, @@ -514,6 +522,8 @@ export const LensTopNavMenu = ({ const lensStore = useStore(); + const adHocDataViews = indexPatterns.filter((pattern) => !pattern.isPersisted()); + const topNavConfig = useMemo(() => { const showReplaceInDashboard = initialContext?.originatingApp === 'dashboards' && @@ -523,20 +533,23 @@ export const LensTopNavMenu = ({ !(initialInput as LensByReferenceInput)?.savedObjectId; const contextFromEmbeddable = initialContext && 'isEmbeddable' in initialContext && initialContext.isEmbeddable; + const showSaveAndReturn = + !(showReplaceInDashboard || showReplaceInCanvas) && + (Boolean( + isLinkedToOriginatingApp && + // Temporarily required until the 'by value' paradigm is default. + (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) + ) || + Boolean(initialContextIsEmbedded)); + + const hasData = Boolean(activeData && Object.keys(activeData).length); + const csvEnabled = Boolean(isSaveable && hasData); + const shareUrlEnabled = Boolean(application.capabilities.visualize.createShortUrl && hasData); + + const showShareMenu = csvEnabled || shareUrlEnabled; const baseMenuEntries = getLensTopNavConfig({ - showSaveAndReturn: - !(showReplaceInDashboard || showReplaceInCanvas) && - (Boolean( - isLinkedToOriginatingApp && - // Temporarily required until the 'by value' paradigm is default. - (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) - ) || - Boolean(initialContextIsEmbedded)), - enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length), - showOpenInDiscover: Boolean(layerMetaInfo?.isVisible), isByValueMode: getIsByValueMode(), allowByValue: dashboardFeatureFlag.allowByValueEmbeddables, - showCancel: Boolean(isLinkedToOriginatingApp), savingToLibraryPermitted, savingToDashboardPermitted, isSaveable, @@ -544,155 +557,205 @@ export const LensTopNavMenu = ({ showReplaceInDashboard, showReplaceInCanvas, contextFromEmbeddable, - tooltips: { - showExportWarning: () => { - if (activeData) { - const datatables = Object.values(activeData); - const formulaDetected = datatables.some((datatable) => { - return tableHasFormulas(datatable.columns, datatable.rows); - }); - if (formulaDetected) { - return i18n.translate('xpack.lens.app.downloadButtonFormulasWarning', { - defaultMessage: - 'Your CSV contains characters that spreadsheet applications might interpret as formulas.', + actions: { + inspect: { visible: true, execute: () => lensInspector.inspect({ title }) }, + share: { + visible: true, + enabled: showShareMenu, + tooltip: () => { + if (!showShareMenu) { + return i18n.translate('xpack.lens.app.shareButtonDisabledWarning', { + defaultMessage: 'The visualization has no data to share.', }); } - } - return undefined; - }, - showUnderlyingDataWarning: () => { - return layerMetaInfo?.error; - }, - }, - actions: { - inspect: () => lensInspector.inspect({ title }), - exportToCSV: () => { - if (!activeData) { - return; - } - const datatables = Object.values(activeData); - const content = datatables.reduce>( - (memo, datatable, i) => { - // skip empty datatables - if (datatable) { - const postFix = datatables.length > 1 ? `-${i + 1}` : ''; + }, + execute: async (anchorElement) => { + if (!share) { + return; + } + const sharingData = { + activeData, + csvEnabled, + title: title || unsavedTitle, + }; - memo[`${title || unsavedTitle}${postFix}.csv`] = { - content: exporters.datatableToCSV(datatable, { - csvSeparator: uiSettings.get('csv:separator', ','), - quoteValues: uiSettings.get('csv:quoteValues', true), - formatFactory: fieldFormats.deserialize, - escapeFormulaValues: false, - }), - type: exporters.CSV_MIME_TYPE, - }; - } - return memo; - }, - {} - ); - if (content) { - downloadMultipleAs(content); - } - }, - saveAndReturn: () => { - if (isSaveable) { - // disabling the validation on app leave because the document has been saved. - onAppLeave((actions) => { - return actions.default(); - }); - runSave( + const { shareableUrl, savedObjectURL } = await getShareURL( + shortUrlService, + { application, data }, { - newTitle: - title || - (initialContext && 'isEmbeddable' in initialContext && initialContext.isEmbeddable - ? i18n.translate('xpack.lens.app.convertedLabel', { - defaultMessage: '{title} (converted)', - values: { - title: - initialContext.title || `${initialContext.visTypeTitle} visualization`, - }, - }) - : ''), - newCopyOnSave: false, - isTitleDuplicateConfirmed: false, - returnToOrigin: true, - }, - { - saveToLibrary: - (initialInput && attributeService.inputIsRefType(initialInput)) ?? false, + filters, + query, + activeDatasourceId, + datasourceStates, + datasourceMap, + visualizationMap, + visualization, + currentDoc, + adHocDataViews: adHocDataViews.map((dataView) => dataView.toSpec()), } ); - } + + share.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: false, // we'll manage this implicitly via the new service + shareableUrl: shareableUrl || '', + shareableUrlForSavedObject: savedObjectURL.href, + objectId: currentDoc?.savedObjectId, + objectType: 'lens_visualization', + objectTypeTitle: i18n.translate('xpack.lens.app.share.panelTitle', { + defaultMessage: 'visualization', + }), + sharingData, + isDirty: isCurrentStateDirty, + // disable the menu if both shortURL permission and the visualization has not been saved + // TODO: improve here the disabling state with more specific checks + disabledShareUrl: Boolean(!shareUrlEnabled && !currentDoc?.savedObjectId), + showPublicUrlSwitch: () => false, + onClose: () => { + anchorElement?.focus(); + }, + }); + }, }, - showSaveModal: () => { - if (savingToDashboardPermitted || savingToLibraryPermitted) { - setIsSaveModalVisible(true); - } + saveAndReturn: { + visible: showSaveAndReturn, + enabled: isSaveable, + execute: () => { + if (isSaveable) { + // disabling the validation on app leave because the document has been saved. + onAppLeave((actions) => { + return actions.default(); + }); + runSave( + { + newTitle: + title || + (initialContext && + 'isEmbeddable' in initialContext && + initialContext.isEmbeddable + ? i18n.translate('xpack.lens.app.convertedLabel', { + defaultMessage: '{title} (converted)', + values: { + title: + initialContext.title || + `${initialContext.visTypeTitle} visualization`, + }, + }) + : ''), + newCopyOnSave: false, + isTitleDuplicateConfirmed: false, + returnToOrigin: true, + }, + { + saveToLibrary: + (initialInput && attributeService.inputIsRefType(initialInput)) ?? false, + } + ); + } + }, }, - goBack: () => { - if (contextOriginatingApp) { - goBackToOriginatingApp?.(); - } + showSaveModal: { + visible: Boolean(savingToDashboardPermitted || savingToLibraryPermitted), + execute: () => { + if (savingToDashboardPermitted || savingToLibraryPermitted) { + setIsSaveModalVisible(true); + } + }, }, - cancel: () => { - if (redirectToOrigin) { - redirectToOrigin(); - } + goBack: { + visible: Boolean(contextOriginatingApp), + enabled: Boolean(contextOriginatingApp), + execute: () => { + if (contextOriginatingApp) { + goBackToOriginatingApp?.(); + } + }, }, - getUnderlyingDataUrl: () => { - if (!layerMetaInfo) { - return; - } - const { error, meta } = layerMetaInfo; - // If Discover is not available, return - // If there's no data, return - if (error || !discoverLocator || !meta) { - return; - } - const { filters: newFilters, query: newQuery } = combineQueryAndFilters( - query, - filters, - meta, - indexPatterns, - getEsQueryConfig(uiSettings) - ); + cancel: { + visible: Boolean(isLinkedToOriginatingApp), + execute: () => { + if (redirectToOrigin) { + redirectToOrigin(); + } + }, + }, + getUnderlyingDataUrl: { + visible: Boolean(layerMetaInfo?.isVisible), + enabled: !layerMetaInfo?.error, + tooltip: () => { + return layerMetaInfo?.error; + }, + execute: () => {}, + getLink: () => { + if (!layerMetaInfo) { + return; + } + const { error, meta } = layerMetaInfo; + // If Discover is not available, return + // If there's no data, return + if (error || !discoverLocator || !meta) { + return; + } + const { filters: newFilters, query: newQuery } = combineQueryAndFilters( + query, + filters, + meta, + indexPatterns, + getEsQueryConfig(uiSettings) + ); - return discoverLocator.getRedirectUrl({ - dataViewSpec: dataViews.indexPatterns[meta.id]?.spec, - timeRange: data.query.timefilter.timefilter.getTime(), - filters: newFilters, - query: isOnTextBasedMode ? query : newQuery, - columns: meta.columns, - }); + return discoverLocator.getRedirectUrl({ + dataViewSpec: dataViews.indexPatterns[meta.id]?.spec, + timeRange: data.query.timefilter.timefilter.getTime(), + filters: newFilters, + query: isOnTextBasedMode ? query : newQuery, + columns: meta.columns, + }); + }, + }, + openSettings: { + visible: true, + execute: (anchorElement) => + toggleSettingsMenuOpen({ + lensStore, + anchorElement, + theme$, + }), }, - openSettings: (anchorElement: HTMLElement) => - toggleSettingsMenuOpen({ - lensStore, - anchorElement, - theme$, - }), }, }); return [...(additionalMenuEntries || []), ...baseMenuEntries]; }, [ + initialContext, + initialInput, isLinkedToOriginatingApp, dashboardFeatureFlag.allowByValueEmbeddables, - initialInput, initialContextIsEmbedded, - isSaveable, activeData, - layerMetaInfo, + isSaveable, + shortUrlService, + application, getIsByValueMode, savingToLibraryPermitted, savingToDashboardPermitted, contextOriginatingApp, + layerMetaInfo, additionalMenuEntries, lensInspector, title, + share, unsavedTitle, - uiSettings, - fieldFormats.deserialize, + data, + filters, + query, + activeDatasourceId, + datasourceStates, + datasourceMap, + visualizationMap, + visualization, + currentDoc, + isCurrentStateDirty, onAppLeave, runSave, attributeService, @@ -700,15 +763,13 @@ export const LensTopNavMenu = ({ goBackToOriginatingApp, redirectToOrigin, discoverLocator, - query, - filters, indexPatterns, + uiSettings, dataViews.indexPatterns, - data.query.timefilter.timefilter, isOnTextBasedMode, lensStore, theme$, - initialContext, + adHocDataViews, ]); const onQuerySubmitWrapped = useCallback( @@ -919,7 +980,7 @@ export const LensTopNavMenu = ({ onAddField: addField, onDataViewCreated: createNewDataView, onCreateDefaultAdHocDataView, - adHocDataViews: indexPatterns.filter((pattern) => !pattern.isPersisted()), + adHocDataViews, onChangeDataView: async (newIndexPatternId: string) => { const currentDataView = await data.dataViews.get(newIndexPatternId); setCurrentIndexPattern(currentDataView); diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 81cc7df0b005d7..fb791c471fcb5e 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -52,12 +52,42 @@ import { } from '../state_management'; import { getPreloadedState, setState } from '../state_management/lens_slice'; import { getLensInspectorService } from '../lens_inspector_service'; +import { + LensAppLocator, + LENS_SHARE_STATE_ACTION, + MainHistoryLocationState, +} from '../../common/locator/locator'; + +function getInitialContext(history: AppMountParameters['history']) { + const historyLocationState = history.location.state as + | MainHistoryLocationState + | HistoryLocationState + | undefined; + + if (historyLocationState) { + if (historyLocationState.type === LENS_SHARE_STATE_ACTION) { + return { + contextType: historyLocationState.type, + initialStateFromLocator: historyLocationState.payload, + }; + } + // get state from location, used for navigating from Visualize/Discover to Lens + if ([ACTION_VISUALIZE_LENS_FIELD, ACTION_CONVERT_TO_LENS].includes(historyLocationState.type)) { + return { + contextType: historyLocationState.type, + initialContext: historyLocationState.payload, + originatingApp: historyLocationState.originatingApp, + }; + } + } +} export async function getLensServices( coreStart: CoreStart, startDependencies: LensPluginStartDependencies, attributeService: LensAttributeService, - initialContext?: VisualizeFieldContext | VisualizeEditorContext + initialContext?: VisualizeFieldContext | VisualizeEditorContext, + locator?: LensAppLocator ): Promise { const { data, @@ -112,6 +142,7 @@ export async function getLensServices( share, unifiedSearch, docLinks: coreStart.docLinks, + locator, }; } @@ -123,6 +154,7 @@ export async function mountApp( attributeService: LensAttributeService; getPresentationUtilContext: () => FC; topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[]; + locator?: LensAppLocator; } ) { const { @@ -130,26 +162,22 @@ export async function mountApp( attributeService, getPresentationUtilContext, topNavMenuEntryGenerators, + locator, } = mountProps; const [[coreStart, startDependencies], instance] = await Promise.all([ core.getStartServices(), createEditorFrame(), ]); - const historyLocationState = params.history.location.state as HistoryLocationState; - // get state from location, used for navigating from Visualize/Discover to Lens - const initialContext = - historyLocationState && - (historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD || - historyLocationState.type === ACTION_CONVERT_TO_LENS) - ? historyLocationState.payload - : undefined; + const { contextType, initialContext, initialStateFromLocator, originatingApp } = + getInitialContext(params.history) || {}; const lensServices = await getLensServices( coreStart, startDependencies, attributeService, - initialContext + initialContext, + locator ); const { stateTransfer, data } = lensServices; @@ -195,8 +223,9 @@ export async function mountApp( const redirectToOrigin = (props?: RedirectToOriginProps) => { const contextOriginatingApp = initialContext && 'originatingApp' in initialContext ? initialContext.originatingApp : null; - const originatingApp = embeddableEditorIncomingState?.originatingApp ?? contextOriginatingApp; - if (!originatingApp) { + const mergedOriginatingApp = + embeddableEditorIncomingState?.originatingApp ?? contextOriginatingApp; + if (!mergedOriginatingApp) { throw new Error('redirectToOrigin called without an originating app'); } let embeddableId = embeddableEditorIncomingState?.embeddableId; @@ -205,7 +234,7 @@ export async function mountApp( } if (stateTransfer && props?.input) { const { input, isCopied } = props; - stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { + stateTransfer.navigateToWithEmbeddablePackage(mergedOriginatingApp, { path: embeddableEditorIncomingState?.originatingPath, state: { embeddableId: isCopied ? undefined : embeddableId, @@ -215,17 +244,17 @@ export async function mountApp( }, }); } else { - coreStart.application.navigateToApp(originatingApp, { + coreStart.application.navigateToApp(mergedOriginatingApp, { path: embeddableEditorIncomingState?.originatingPath, }); } }; - if (historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD) { + if (contextType === ACTION_VISUALIZE_LENS_FIELD && initialContext?.originatingApp) { // remove originatingApp from context when visualizing a field in Lens // so Lens does not try to return to the original app on Save // see https://github.com/elastic/kibana/issues/128695 - delete initialContext?.originatingApp; + delete initialContext.originatingApp; } if (embeddableEditorIncomingState?.searchSessionId) { @@ -239,6 +268,7 @@ export async function mountApp( visualizationMap, embeddableEditorIncomingState, initialContext, + initialStateFromLocator, }; const lensStore: LensRootStore = makeConfigureStore(storeDeps, { lens: getPreloadedState(storeDeps) as LensAppState, @@ -247,6 +277,7 @@ export async function mountApp( const EditorRenderer = React.memo( (props: { id?: string; history: History; editByValue?: boolean }) => { const [editorState, setEditorState] = useState<'loading' | 'no_data' | 'data'>('loading'); + useEffect(() => { const kbnUrlStateStorage = createKbnUrlStateStorage({ history: props.history, @@ -268,14 +299,14 @@ export async function mountApp( }, [props.history] ); - const initialInput = useMemo( - () => getInitialInput(props.id, props.editByValue), - [props.editByValue, props.id] - ); + const initialInput = useMemo(() => { + return getInitialInput(props.id, props.editByValue); + }, [props.editByValue, props.id]); + const initCallback = useCallback(() => { // Clear app-specific filters when navigating to Lens. Necessary because Lens - // can be loaded without a full page refresh. If the user navigates to Lens from Discover - // we keep the filters + // can be loaded without a full page refresh. + // If the user navigates to Lens from Discover, or comes from a Lens share link we keep the filters if (!initialContext) { data.query.filterManager.setAppFilters([]); } @@ -330,7 +361,7 @@ export async function mountApp( datasourceMap={datasourceMap} visualizationMap={visualizationMap} initialContext={initialContext} - contextOriginatingApp={historyLocationState?.originatingApp} + contextOriginatingApp={originatingApp} topNavMenuEntryGenerators={topNavMenuEntryGenerators} theme$={core.theme.theme$} /> diff --git a/x-pack/plugins/lens/public/app_plugin/share_action.ts b/x-pack/plugins/lens/public/app_plugin/share_action.ts new file mode 100644 index 00000000000000..13ff9d53f25f14 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/share_action.ts @@ -0,0 +1,105 @@ +/* + * 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 type { SavedObjectReference } from '@kbn/core-saved-objects-common'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { DataViewSpec } from '@kbn/data-views-plugin/common'; +import type { LensAppLocatorParams } from '../../common/locator/locator'; +import type { LensAppState } from '../state_management'; +import type { LensAppServices } from './types'; +import type { Document } from '../persistence/saved_object_store'; +import type { DatasourceMap, VisualizationMap } from '../types'; +import { extractReferencesFromState, getResolvedDateRange } from '../utils'; +import { getEditPath } from '../../common'; + +interface ShareableConfiguration + extends Pick< + LensAppState, + 'activeDatasourceId' | 'datasourceStates' | 'visualization' | 'filters' | 'query' + > { + datasourceMap: DatasourceMap; + visualizationMap: VisualizationMap; + currentDoc: Document | undefined; + adHocDataViews?: DataViewSpec[]; +} + +function getShareURLForSavedObject( + { application, data }: Pick, + currentDoc: Document | undefined +) { + return new URL( + `${application.getUrlForApp('lens', { absolute: true })}${ + currentDoc?.savedObjectId + ? getEditPath( + currentDoc?.savedObjectId, + data.query.timefilter.timefilter.getTime(), + data.query.filterManager.getGlobalFilters(), + data.query.timefilter.timefilter.getRefreshInterval() + ) + : '' + }` + ); +} + +function getShortShareableURL( + shortUrlService: (params: LensAppLocatorParams) => Promise, + data: LensAppServices['data'], + { + filters, + query, + activeDatasourceId, + datasourceStates, + datasourceMap, + visualizationMap, + visualization, + adHocDataViews, + }: ShareableConfiguration +) { + const references = extractReferencesFromState({ + activeDatasources: Object.keys(datasourceStates).reduce( + (acc, datasourceId) => ({ + ...acc, + [datasourceId]: datasourceMap[datasourceId], + }), + {} + ), + datasourceStates, + visualizationState: visualization.state, + activeVisualization: visualization.activeId + ? visualizationMap[visualization.activeId] + : undefined, + }) as Array; + + const serializableVisualization = visualization as LensAppState['visualization'] & + SerializableRecord; + + const serializableDatasourceStates = datasourceStates as LensAppState['datasourceStates'] & + SerializableRecord; + + return shortUrlService({ + filters, + query, + resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter), + visualization: serializableVisualization, + datasourceStates: serializableDatasourceStates, + activeDatasourceId, + searchSessionId: data.search.session.getSessionId(), + references, + dataViewSpecs: adHocDataViews, + }); +} + +export async function getShareURL( + shortUrlService: (params: LensAppLocatorParams) => Promise, + services: Pick, + configuration: ShareableConfiguration +) { + return { + shareableUrl: await getShortShareableURL(shortUrlService, services.data, configuration), + savedObjectURL: getShareURLForSavedObject(services, configuration.currentDoc), + }; +} diff --git a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts index 68059b293f2f9b..311541cdf905c6 100644 --- a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts +++ b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.test.ts @@ -16,6 +16,9 @@ describe('getLayerMetaInfo', () => { navLinks: { discover: true }, discover: { show: true }, }; + const indexPatternsMap = { + test: createMockedIndexPattern(), + }; it('should return error in case of no data', () => { expect( getLayerMetaInfo( @@ -24,7 +27,7 @@ describe('getLayerMetaInfo', () => { createMockVisualization('testVisualization'), {}, undefined, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -43,7 +46,7 @@ describe('getLayerMetaInfo', () => { { datatable1: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -58,7 +61,7 @@ describe('getLayerMetaInfo', () => { createMockVisualization('testVisualization'), {}, undefined, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -73,7 +76,7 @@ describe('getLayerMetaInfo', () => { createMockVisualization('testVisualization'), {}, {}, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -88,7 +91,7 @@ describe('getLayerMetaInfo', () => { undefined, {}, undefined, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -103,7 +106,7 @@ describe('getLayerMetaInfo', () => { createMockVisualization('testVisualization'), undefined, {}, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -126,7 +129,7 @@ describe('getLayerMetaInfo', () => { datatable1: { type: 'datatable', columns: [], rows: [] }, datatable2: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -154,7 +157,7 @@ describe('getLayerMetaInfo', () => { { datatable1: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -181,7 +184,7 @@ describe('getLayerMetaInfo', () => { createMockVisualization('testVisualization'), {}, {}, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -203,7 +206,7 @@ describe('getLayerMetaInfo', () => { { datatable1: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, capabilities ).error @@ -226,7 +229,7 @@ describe('getLayerMetaInfo', () => { { datatable1: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, { navLinks: { discover: false }, @@ -243,7 +246,7 @@ describe('getLayerMetaInfo', () => { { datatable1: { type: 'datatable', columns: [], rows: [] }, }, - {}, + indexPatternsMap, undefined, { navLinks: { discover: true }, diff --git a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts index bc0e926e645893..a181cea7945841 100644 --- a/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts +++ b/x-pack/plugins/lens/public/app_plugin/show_underlying_data.ts @@ -99,8 +99,15 @@ export function getLayerMetaInfo( const isVisible = Boolean(capabilities.navLinks?.discover && capabilities.discover?.show); // If Multiple tables, return // If there are time shifts, return + // If dataViews have not loaded yet, return const datatables = Object.values(activeData || {}); - if (!datatables.length || !currentDatasource || !datasourceState || !activeVisualization) { + if ( + !datatables.length || + !currentDatasource || + !datasourceState || + !activeVisualization || + !Object.keys(indexPatterns).length + ) { return { meta: undefined, error: i18n.translate('xpack.lens.app.showUnderlyingDataNoData', { diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 831b7ce54da39f..1411598c4033e7 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -56,6 +56,7 @@ import type { LensEmbeddableInput } from '../embeddable/embeddable'; import type { LensInspector } from '../lens_inspector_service'; import { IndexPatternServiceAPI } from '../data_views_service/service'; import { Document } from '../persistence/saved_object_store'; +import { type LensAppLocator, LensAppLocatorParams } from '../../common/locator/locator'; export interface RedirectToOriginProps { input?: LensEmbeddableInput; @@ -120,6 +121,8 @@ export interface LensTopNavMenuProps { theme$: Observable; indexPatternService: IndexPatternServiceAPI; onTextBasedSavedAndExit: ({ onSave }: { onSave: () => void }) => Promise; + shortUrlService: (params: LensAppLocatorParams) => Promise; + isCurrentStateDirty: boolean; } export interface HistoryLocationState { @@ -160,20 +163,24 @@ export interface LensAppServices { dashboardFeatureFlag: DashboardFeatureFlagConfig; dataViewEditor: DataViewEditorStart; dataViewFieldEditor: IndexPatternFieldEditorStart; + locator?: LensAppLocator; } -export interface LensTopNavTooltips { - showExportWarning: () => string | undefined; - showUnderlyingDataWarning: () => string | undefined; +interface TopNavAction { + visible: boolean; + enabled?: boolean; + execute: (anchorElement: HTMLElement) => void; + getLink?: () => string | undefined; + tooltip?: () => string | undefined; } -export interface LensTopNavActions { - inspect: () => void; - saveAndReturn: () => void; - showSaveModal: () => void; - goBack: () => void; - cancel: () => void; - exportToCSV: () => void; - getUnderlyingDataUrl: () => string | undefined; - openSettings: (anchorElement: HTMLElement) => void; -} +type AvailableTopNavActions = + | 'inspect' + | 'saveAndReturn' + | 'showSaveModal' + | 'goBack' + | 'cancel' + | 'share' + | 'getUnderlyingDataUrl' + | 'openSettings'; +export type LensTopNavActions = Record; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 8df771d8eb94b5..9f53e4822e2395 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -49,18 +49,19 @@ function getIndexPatterns( adHocDataviews?: string[] ) { const indexPatternIds = []; + + // use the initialId only when no context is passed over + if (!initialContext && initialId) { + indexPatternIds.push(initialId); + } if (initialContext) { if ('isVisualizeAction' in initialContext) { indexPatternIds.push(...initialContext.indexPatternIds); } else { indexPatternIds.push(initialContext.dataViewSpec.id!); } - } else { - // use the initialId only when no context is passed over - if (initialId) { - indexPatternIds.push(initialId); - } } + if (references) { for (const reference of references) { if (reference.type === 'index-pattern') { diff --git a/x-pack/plugins/lens/public/mocks/services_mock.tsx b/x-pack/plugins/lens/public/mocks/services_mock.tsx index 81aa4e0617931c..019a37001312cb 100644 --- a/x-pack/plugins/lens/public/mocks/services_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/services_mock.tsx @@ -157,7 +157,7 @@ export function makeDefaultServices( ...core.application, capabilities: { ...core.application.capabilities, - visualize: { save: true, saveQuery: true, show: true }, + visualize: { save: true, saveQuery: true, show: true, createShortUrl: true }, }, getUrlForApp: jest.fn((appId: string) => `/testbasepath/app/${appId}#/`), }, diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index f0c09a9fe31a7e..2b3dce55839782 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -110,6 +110,8 @@ import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; import { OpenInDiscoverDrilldown } from './trigger_actions/open_in_discover_drilldown'; import { ChartInfoApi } from './chart_info_api'; +import { type LensAppLocator, LensAppLocatorDefinition } from '../common/locator/locator'; +import { downloadCsvShareProvider } from './app_plugin/csv_download_provider/csv_download_provider'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -250,6 +252,7 @@ export class LensPlugin { private hasDiscoverAccess: boolean = false; private dataViewsService: DataViewsPublicPluginStart | undefined; private initDependenciesForApi: () => void = () => {}; + private locator?: LensAppLocator; setup( core: CoreSetup, @@ -324,6 +327,17 @@ export class LensPlugin { embeddable.registerEmbeddableFactory('lens', new EmbeddableFactory(getStartServices)); } + if (share) { + this.locator = share.url.locators.create(new LensAppLocatorDefinition()); + + share.register( + downloadCsvShareProvider({ + uiSettings: core.uiSettings, + formatFactoryFn: () => startServices().plugins.fieldFormats.deserialize, + }) + ); + } + visualizations.registerAlias(getLensAliasConfig()); uiActionsEnhanced.registerDrilldown( @@ -384,6 +398,7 @@ export class LensPlugin { attributeService: getLensAttributeService(coreStart, deps), getPresentationUtilContext, topNavMenuEntryGenerators: this.topNavMenuEntries, + locator: this.locator, }); }, }); diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index 7ca55e94473922..97c08a1ad32589 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -103,6 +103,7 @@ export function loadInitial( datasourceMap, embeddableEditorIncomingState, initialContext, + initialStateFromLocator, visualizationMap, } = storeDeps; const { resolvedDateRange, searchSessionId, isLinkedToOriginatingApp, ...emptyState } = @@ -121,6 +122,82 @@ export function loadInitial( activeDatasourceId = 'textBased'; } + if (initialStateFromLocator) { + const locatorReferences = + 'references' in initialStateFromLocator ? initialStateFromLocator.references : undefined; + + const newFilters = initialStateFromLocator.filters + ? cloneDeep(initialStateFromLocator.filters) + : undefined; + + if (newFilters) { + data.query.filterManager.setAppFilters(newFilters); + } + + if (initialStateFromLocator.resolvedDateRange) { + const newTimeRange = { + from: initialStateFromLocator.resolvedDateRange.fromDate, + to: initialStateFromLocator.resolvedDateRange.toDate, + }; + data.query.timefilter.timefilter.setTime(newTimeRange); + } + + return initializeSources( + { + datasourceMap, + visualizationMap, + visualizationState: emptyState.visualization, + datasourceStates: emptyState.datasourceStates, + initialContext, + adHocDataViews: + lens.persistedDoc?.state.adHocDataViews || initialStateFromLocator.dataViewSpecs, + references: locatorReferences, + ...loaderSharedArgs, + }, + { + isFullEditor: true, + } + ) + .then(({ datasourceStates, visualizationState, indexPatterns, indexPatternRefs }) => { + const currentSessionId = + initialStateFromLocator?.searchSessionId || data.search.session.getSessionId(); + store.dispatch( + setState({ + isSaveable: true, + filters: initialStateFromLocator.filters || data.query.filterManager.getFilters(), + query: initialStateFromLocator.query || emptyState.query, + searchSessionId: currentSessionId, + activeDatasourceId: emptyState.activeDatasourceId, + visualization: { + activeId: emptyState.visualization.activeId, + state: visualizationState, + }, + dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs), + datasourceStates: Object.entries(datasourceStates).reduce( + (state, [datasourceId, datasourceState]) => ({ + ...state, + [datasourceId]: { + ...datasourceState, + isLoading: false, + }, + }), + {} + ), + isLoading: false, + }) + ); + + if (autoApplyDisabled) { + store.dispatch(disableAutoApply()); + } + }) + .catch((e: { message: string }) => { + notifications.toasts.addDanger({ + title: e.message, + }); + }); + } + if ( !initialInput || (attributeService.inputIsRefType(initialInput) && diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index e74e8c94edede0..da64209a8a80f3 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -56,12 +56,33 @@ export const initialState: LensAppState = { export const getPreloadedState = ({ lensServices: { data }, initialContext, + initialStateFromLocator, embeddableEditorIncomingState, datasourceMap, visualizationMap, }: LensStoreDeps) => { const initialDatasourceId = getInitialDatasourceId(datasourceMap); const datasourceStates: LensAppState['datasourceStates'] = {}; + if (initialStateFromLocator) { + if ('datasourceStates' in initialStateFromLocator) { + Object.keys(datasourceMap).forEach((datasourceId) => { + datasourceStates[datasourceId] = { + state: initialStateFromLocator.datasourceStates[datasourceId], + isLoading: true, + }; + }); + } + return { + ...initialState, + isLoading: true, + ...initialStateFromLocator, + activeDatasourceId: + ('activeDatasourceId' in initialStateFromLocator && + initialStateFromLocator.activeDatasourceId) || + initialDatasourceId, + datasourceStates, + }; + } if (initialDatasourceId) { Object.keys(datasourceMap).forEach((datasourceId) => { datasourceStates[datasourceId] = { diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 4f7500ec20a5ed..a25ca282a85ea9 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; -import { EmbeddableEditorState } from '@kbn/embeddable-plugin/public'; +import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; +import type { EmbeddableEditorState } from '@kbn/embeddable-plugin/public'; import type { Filter, Query } from '@kbn/es-query'; -import { SavedQuery } from '@kbn/data-plugin/public'; -import { Document } from '../persistence'; +import type { SavedQuery } from '@kbn/data-plugin/public'; +import type { MainHistoryLocationState } from '../../common/locator/locator'; +import type { Document } from '../persistence'; import type { TableInspectorAdapter } from '../editor_frame_service/types'; -import { DateRange } from '../../common'; -import { LensAppServices } from '../app_plugin/types'; -import { +import type { DateRange } from '../../common'; +import type { LensAppServices } from '../app_plugin/types'; +import type { DatasourceMap, VisualizationMap, SharingSavedObjectProps, @@ -79,5 +80,6 @@ export interface LensStoreDeps { datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; initialContext?: VisualizeFieldContext | VisualizeEditorContext; + initialStateFromLocator?: MainHistoryLocationState['payload']; embeddableEditorIncomingState?: EmbeddableEditorState; } diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 16ad6a026851dc..c268a79599e77c 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -120,7 +120,7 @@ export async function refreshIndexPatternsList({ }); } -export function getIndexPatternsIds({ +export function extractReferencesFromState({ activeDatasources, datasourceStates, visualizationState, @@ -130,13 +130,10 @@ export function getIndexPatternsIds({ datasourceStates: DatasourceStates; visualizationState: unknown; activeVisualization?: Visualization; -}): string[] { - let currentIndexPatternId: string | undefined; +}): SavedObjectReference[] { const references: SavedObjectReference[] = []; Object.entries(activeDatasources).forEach(([id, datasource]) => { const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state); - const indexPatternId = datasource.getUsedDataView(datasourceStates[id].state); - currentIndexPatternId = indexPatternId; references.push(...savedObjectReferences); }); @@ -144,6 +141,35 @@ export function getIndexPatternsIds({ const { savedObjectReferences } = activeVisualization.getPersistableState(visualizationState); references.push(...savedObjectReferences); } + return references; +} + +export function getIndexPatternsIds({ + activeDatasources, + datasourceStates, + visualizationState, + activeVisualization, +}: { + activeDatasources: Record; + datasourceStates: DatasourceStates; + visualizationState: unknown; + activeVisualization?: Visualization; +}): string[] { + const references: SavedObjectReference[] = extractReferencesFromState({ + activeDatasources, + datasourceStates, + visualizationState, + activeVisualization, + }); + + const currentIndexPatternId: string | undefined = Object.entries(activeDatasources).reduce< + string | undefined + >((currentId, [id, datasource]) => { + if (currentId == null) { + return datasource.getUsedDataView(datasourceStates[id].state); + } + return currentId; + }, undefined); const referencesIds = references .filter(({ type }) => type === 'index-pattern') .map(({ id }) => id); diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index 03adef0d2b10ad..b455ced2b8767b 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -21,16 +21,19 @@ import { } from '@kbn/task-manager-plugin/server'; import { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; +import { SharePluginSetup } from '@kbn/share-plugin/server'; import { setupSavedObjects } from './saved_objects'; import { setupExpressions } from './expressions'; import { makeLensEmbeddableFactory } from './embeddable/make_lens_embeddable_factory'; import type { CustomVisualizationMigrations } from './migrations/types'; +import { LensAppLocatorDefinition } from '../common/locator/locator'; export interface PluginSetupContract { taskManager?: TaskManagerSetupContract; embeddable: EmbeddableSetup; expressions: ExpressionsServerSetup; data: DataPluginSetup; + share?: SharePluginSetup; } export interface PluginStartContract { @@ -66,6 +69,10 @@ export class LensServerPlugin implements Plugin { + const url = await PageObjects.lens.getUrl('snapshot'); + await browser.openNewTab(); + + const [lensWindowHandler] = await browser.getAllWindowHandles(); + + await browser.navigateTo(url); + // check that it's the same configuration in the new URL when ready + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await PageObjects.lens.getDimensionTriggerText('lnsMetric_primaryMetricDimensionPanel') + ).to.eql('Average of bytes'); + await browser.closeCurrentWindow(); + await browser.switchToWindow(lensWindowHandler); + }); + + it('should be possible to download a visualization with adhoc dataViews', async () => { + await PageObjects.lens.setCSVDownloadDebugFlag(true); + await PageObjects.lens.openCSVDownloadShare(); + + const csv = await PageObjects.lens.getCSVContent(); + expect(csv).to.be.ok(); + expect(Object.keys(csv!)).to.have.length(1); + await PageObjects.lens.setCSVDownloadDebugFlag(false); + }); + it('should navigate to discover correctly', async () => { await testSubjects.clickWhenNotDisabledWithoutRetry(`lnsApp_openInDiscover`); @@ -230,6 +256,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // adhoc data view should be persisted after refresh await browser.refresh(); await checkDiscoverNavigationResult(); + + await browser.closeCurrentWindow(); + await browser.switchToWindow(daashboardHandle); }); }); } diff --git a/x-pack/test/functional/apps/lens/group1/smokescreen.ts b/x-pack/test/functional/apps/lens/group1/smokescreen.ts index c01fd3a848aafb..5470b99bcd8f22 100644 --- a/x-pack/test/functional/apps/lens/group1/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/group1/smokescreen.ts @@ -680,27 +680,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal(indexPatternString); }); - it('should show a download button only when the configuration is valid', async () => { - await PageObjects.visualize.navigateToNewVisualization(); - await PageObjects.visualize.clickVisType('lens'); - await PageObjects.lens.goToTimeRange(); - await PageObjects.lens.switchToVisualization('pie'); - await PageObjects.lens.configureDimension({ - dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', - operation: 'date_histogram', - field: '@timestamp', - }); - // incomplete configuration should not be downloadable - expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(false); - - await PageObjects.lens.configureDimension({ - dimension: 'lnsPie_sizeByDimensionPanel > lns-empty-dimension', - operation: 'average', - field: 'bytes', - }); - expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); - }); - it('should allow filtering by legend on an xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/lens/group1/text_based_languages.ts b/x-pack/test/functional/apps/lens/group1/text_based_languages.ts index 2050bead5a91fa..e425b2fe71839c 100644 --- a/x-pack/test/functional/apps/lens/group1/text_based_languages.ts +++ b/x-pack/test/functional/apps/lens/group1/text_based_languages.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'dashboard', 'common', ]); + const browser = getService('browser'); const elasticChart = getService('elasticChart'); const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); @@ -93,6 +94,35 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { assertMatchesExpectedData(data!); }); + it('should be possible to share a URL of a visualization with text-based language', async () => { + const url = await PageObjects.lens.getUrl('snapshot'); + await browser.openNewTab(); + + const [lensWindowHandler] = await browser.getAllWindowHandles(); + + await browser.navigateTo(url); + // check that it's the same configuration in the new URL when ready + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0, true) + ).to.eql('extension'); + expect( + await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel', 0, true) + ).to.eql('average'); + await browser.closeCurrentWindow(); + await browser.switchToWindow(lensWindowHandler); + }); + + it('should be possible to download a visualization with text-based language', async () => { + await PageObjects.lens.setCSVDownloadDebugFlag(true); + await PageObjects.lens.openCSVDownloadShare(); + + const csv = await PageObjects.lens.getCSVContent(); + expect(csv).to.be.ok(); + expect(Object.keys(csv!)).to.have.length(1); + await PageObjects.lens.setCSVDownloadDebugFlag(false); + }); + it('should allow adding an text based languages chart to a dashboard', async () => { await PageObjects.lens.switchToVisualization('lnsMetric'); @@ -158,5 +188,48 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const metricData = await PageObjects.lens.getMetricVisualizationData(); expect(metricData[0].title).to.eql('average'); }); + + it('should be possible to share a URL of a visualization with text-based language that points to an index pattern', async () => { + // TODO: there's some state leakage in Lens when passing from a XY chart to new Metric chart + // which generates a wrong state (even tho it looks to work, starting fresh with such state breaks the editor) + await PageObjects.lens.removeLayer(); + await PageObjects.lens.switchToVisualization('bar'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.configureTextBasedLanguagesDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + field: 'extension', + }); + + await PageObjects.lens.configureTextBasedLanguagesDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + field: 'average', + }); + const url = await PageObjects.lens.getUrl('snapshot'); + await browser.openNewTab(); + + const [lensWindowHandler] = await browser.getAllWindowHandles(); + + await browser.navigateTo(url); + // check that it's the same configuration in the new URL when ready + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel', 0, true) + ).to.eql('extension'); + expect( + await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel', 0, true) + ).to.eql('average'); + await browser.closeCurrentWindow(); + await browser.switchToWindow(lensWindowHandler); + }); + + it('should be possible to download a visualization with text-based language that points to an index pattern', async () => { + await PageObjects.lens.setCSVDownloadDebugFlag(true); + await PageObjects.lens.openCSVDownloadShare(); + + const csv = await PageObjects.lens.getCSVContent(); + expect(csv).to.be.ok(); + expect(Object.keys(csv!)).to.have.length(1); + await PageObjects.lens.setCSVDownloadDebugFlag(false); + }); }); } diff --git a/x-pack/test/functional/apps/lens/group2/index.ts b/x-pack/test/functional/apps/lens/group2/index.ts index 20cb3557356668..277b415a9ab492 100644 --- a/x-pack/test/functional/apps/lens/group2/index.ts +++ b/x-pack/test/functional/apps/lens/group2/index.ts @@ -78,6 +78,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./epoch_millis')); loadTestFile(require.resolve('./show_underlying_data')); loadTestFile(require.resolve('./show_underlying_data_dashboard')); + loadTestFile(require.resolve('./share')); loadTestFile(require.resolve('./tsdb')); }); }; diff --git a/x-pack/test/functional/apps/lens/group2/share.ts b/x-pack/test/functional/apps/lens/group2/share.ts new file mode 100644 index 00000000000000..02febbd1ce4ee2 --- /dev/null +++ b/x-pack/test/functional/apps/lens/group2/share.ts @@ -0,0 +1,142 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']); + const browser = getService('browser'); + const filterBarService = getService('filterBar'); + const queryBar = getService('queryBar'); + + describe('lens share tests', () => { + before(async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + }); + + after(async () => { + await PageObjects.lens.setCSVDownloadDebugFlag(false); + }); + + it('should disable the share button if no request is made', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + expect(await PageObjects.lens.isShareable()).to.eql(false); + }); + + it('should keep the button disabled for incomplete configuration', async () => { + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + expect(await PageObjects.lens.isShareable()).to.eql(false); + }); + + it('should make the share button avaialble as soon as a valid configuration is generated', async () => { + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'average', + field: 'bytes', + }); + + expect(await PageObjects.lens.isShareable()).to.eql(true); + }); + + it('should enable both download and URL sharing for valid configuration', async () => { + await PageObjects.lens.clickShareMenu(); + + expect(await PageObjects.lens.isShareActionEnabled('csvDownload')); + expect(await PageObjects.lens.isShareActionEnabled('permalinks')); + }); + + it('should provide only snapshot url sharing if visualization is not saved yet', async () => { + await PageObjects.lens.openPermalinkShare(); + + const options = await PageObjects.lens.getAvailableUrlSharingOptions(); + expect(options).eql(['snapshot']); + }); + + it('should basically work for snapshot', async () => { + const url = await PageObjects.lens.getUrl('snapshot'); + await browser.openNewTab(); + + const [lensWindowHandler] = await browser.getAllWindowHandles(); + + await browser.navigateTo(url); + // check that it's the same configuration in the new URL when ready + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql( + 'Average of bytes' + ); + await browser.closeCurrentWindow(); + await browser.switchToWindow(lensWindowHandler); + }); + + it('should provide also saved object url sharing if the visualization is shared', async () => { + await PageObjects.lens.save('ASavedVisualizationToShare'); + await PageObjects.lens.openPermalinkShare(); + + const options = await PageObjects.lens.getAvailableUrlSharingOptions(); + expect(options).eql(['snapshot', 'savedObject']); + }); + + it('should preserve filter and query when sharing', async () => { + await filterBarService.addFilter({ field: 'bytes', operation: 'is', value: '1' }); + await queryBar.setQuery('host.keyword www.elastic.co'); + await queryBar.submitQuery(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const url = await PageObjects.lens.getUrl('snapshot'); + await browser.openNewTab(); + + const [lensWindowHandler] = await browser.getAllWindowHandles(); + + await browser.navigateTo(url); + // check that it's the same configuration in the new URL when ready + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await filterBarService.getFiltersLabel()).to.eql(['bytes: 1']); + expect(await queryBar.getQueryString()).to.be('host.keyword www.elastic.co'); + await browser.closeCurrentWindow(); + await browser.switchToWindow(lensWindowHandler); + }); + + it('should be able to download CSV data of the current visualization', async () => { + await PageObjects.lens.setCSVDownloadDebugFlag(true); + await PageObjects.lens.openCSVDownloadShare(); + + const csv = await PageObjects.lens.getCSVContent(); + expect(csv).to.be.ok(); + expect(Object.keys(csv!)).to.have.length(1); + }); + + it('should be able to download CSV of multi layer visualization', async () => { + await PageObjects.lens.createLayer(); + + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-1 > lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lns-layerPanel-1 > lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'median', + field: 'bytes', + }); + + await PageObjects.lens.openCSVDownloadShare(); + + const csv = await PageObjects.lens.getCSVContent(); + expect(csv).to.be.ok(); + expect(Object.keys(csv!)).to.have.length(2); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 49785c62e7310a..10deb555fa3597 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -11,6 +11,16 @@ import { WebElementWrapper } from '../../../../test/functional/services/lib/web_ import { FtrProviderContext } from '../ftr_provider_context'; import { logWrapper } from './log_wrapper'; +declare global { + interface Window { + /** + * Debug setting to test CSV download + */ + ELASTIC_LENS_CSV_DOWNLOAD_DEBUG?: boolean; + ELASTIC_LENS_CSV_CONTENT?: Record; + } +} + export function LensPageProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const findService = getService('find'); @@ -963,8 +973,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param dimension - the selector of the dimension * @param index - the index of the dimension trigger in group */ - async getDimensionTriggerText(dimension: string, index = 0) { - const dimensionTexts = await this.getDimensionTriggersTexts(dimension); + async getDimensionTriggerText(dimension: string, index = 0, isTextBased: boolean = false) { + const dimensionTexts = await this.getDimensionTriggersTexts(dimension, isTextBased); return dimensionTexts[index]; }, /** @@ -972,9 +982,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * * @param dimension - the selector of the dimension */ - async getDimensionTriggersTexts(dimension: string) { + async getDimensionTriggersTexts(dimension: string, isTextBased: boolean = false) { return retry.try(async () => { - const dimensionElements = await testSubjects.findAll(`${dimension} > lns-dimensionTrigger`); + const dimensionElements = await testSubjects.findAll( + `${dimension} > lns-dimensionTrigger${isTextBased ? '-textBased' : ''}` + ); const dimensionTexts = await Promise.all( await dimensionElements.map(async (el) => await el.getVisibleText()) ); @@ -1652,5 +1664,83 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont // map to testSubjId return Promise.all(allFieldsForType.map((el) => el.getAttribute('data-test-subj'))); }, + + async clickShareMenu() { + await testSubjects.click('lnsApp_shareButton'); + }, + + async isShareable() { + return await testSubjects.isEnabled('lnsApp_shareButton'); + }, + + async isShareActionEnabled(action: 'csvDownload' | 'permalinks') { + switch (action) { + case 'csvDownload': + return await testSubjects.isEnabled('sharePanel-CSVDownload'); + case 'permalinks': + return await testSubjects.isEnabled('sharePanel-Permalinks'); + } + }, + + async ensureShareMenuIsOpen(action: 'csvDownload' | 'permalinks') { + await this.clickShareMenu(); + + if (!(await testSubjects.exists('shareContextMenu'))) { + await this.clickShareMenu(); + } + if (!(await this.isShareActionEnabled(action))) { + throw Error(`${action} sharing feature is disabled`); + } + }, + + async openPermalinkShare() { + await this.ensureShareMenuIsOpen('permalinks'); + await testSubjects.click('sharePanel-Permalinks'); + }, + + async getAvailableUrlSharingOptions() { + if (!(await testSubjects.exists('shareUrlForm'))) { + await this.openPermalinkShare(); + } + const el = await testSubjects.find('shareUrlForm'); + const available = await el.findAllByCssSelector('input:not([disabled])'); + const ids = await Promise.all(available.map((node) => node.getAttribute('id'))); + return ids; + }, + + async getUrl(type: 'snapshot' | 'savedObject' = 'snapshot') { + if (!(await testSubjects.exists('shareUrlForm'))) { + await this.openPermalinkShare(); + } + const options = await this.getAvailableUrlSharingOptions(); + const optionIndex = options.findIndex((option) => option === type); + if (optionIndex < 0) { + throw Error(`Sharing URL of type ${type} is not available`); + } + const testSubFrom = `exportAs${type[0].toUpperCase()}${type.substring(1)}`; + await testSubjects.click(testSubFrom); + const copyButton = await testSubjects.find('copyShareUrlButton'); + const url = await copyButton.getAttribute('data-share-url'); + return url; + }, + + async openCSVDownloadShare() { + await this.ensureShareMenuIsOpen('csvDownload'); + await testSubjects.click('sharePanel-CSVDownload'); + }, + + async setCSVDownloadDebugFlag(value: boolean = true) { + await browser.execute<[boolean], void>((v) => { + window.ELASTIC_LENS_CSV_DOWNLOAD_DEBUG = v; + }, value); + }, + + async getCSVContent() { + await testSubjects.click('lnsApp_downloadCSVButton'); + return await browser.execute< + [void], + Record | undefined + >(() => window.ELASTIC_LENS_CSV_CONTENT); + }, }); }