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); + }, }); }