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 19be58c7792b4..6bbcd15168727 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 c964737026b3b..2d3ae3ac1b911 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 969c5dffa864f..f5d3ef0ac652c 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 32441ab2945eb..fb2de6811b4d5 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 a393d4aba6033..d63ceaf115e10 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 b1cd995a5ff84..bbf857e9847aa 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 cfca7391202e5..9de8ba0d569f0 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 329b1cb7d182b..1495410cdb14c 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 1bf3ec49a4780..bfc490fd1e977 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 0000000000000..b91f3d6a0412f
--- /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 0000000000000..ea0e54136ffc9
--- /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 648fd61203943..fbb82b11c0012 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 81cc45e0af432..6c70000ed4e0c 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 0000000000000..59d76f78123fc
--- /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 0000000000000..dded4f4768a16
--- /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 0000000000000..bdcb5e5e74edd
--- /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 2b94c0bf20c6e..4a498cbb23266 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 81cc7df0b005d..fb791c471fcb5 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 0000000000000..13ff9d53f25f1
--- /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 68059b293f2f9..311541cdf905c 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 bc0e926e64589..a181cea794584 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 831b7ce54da39..1411598c4033e 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 8df771d8eb94b..9f53e4822e239 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 81aa4e0617931..019a37001312c 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 f0c09a9fe31a7..2b3dce5583978 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 7ca55e9447392..97c08a1ad3258 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 e74e8c94edede..da64209a8a80f 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 4f7500ec20a5e..a25ca282a85ea 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 16ad6a026851d..c268a79599e77 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 03adef0d2b10a..b455ced2b8767 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 c01fd3a848aaf..5470b99bcd8f2 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 2050bead5a91f..e425b2fe71839 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 20cb355735666..277b415a9ab49 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 0000000000000..02febbd1ce4ee
--- /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 49785c62e7310..10deb555fa359 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);
+ },
});
}