[Lens] Share link feature (#148829)

## Summary

Fixes #75316

* Lens
  * [x] Refactored Top nav actions code to be more modular
  * [x] Created new Locator object for Lens 
    * [x] Enabled server side to make Short URL work with it
    * [x] Added unit tests for it
* [x] Extended `getEditPath` to support `filters` and `refreshInterval`
* [x] Extended mounting code in Lens to handle a new type of incoming
context
* [x] Add new Share action
* [x] Added new `objectTypeTitle` prop to have custom titles on Share
popover
  * [x] Replaced the `Download CSV` action and move it as menu item
    * [x] Refactor code into share item provider + (lazy) panel content
    * [x] Add debug flag to make CSV download testable
    * [x] Add functional tests for CSV download
  * [x] Add Permalink action
    * [x] Integrate Permalink with Short URL service
      * [x] Tweaked Permalink action to work with SO custom URL
      * [x] Tweaked Permalink action to handle disabled state
      * [x] Updated unit tests with new features
* [x] Added (basic) caching logic to avoid too many snapshot duplicate
Short URLs
* [x] New share function test suite created
* [x] Extended `browser` service with a new method to have a blank tab
in browser
  * [x] New helper functions in Lens to test Share feature
  
<img width="375" alt="Screenshot 2023-01-11 at 12 58 30"
src="https://user-images.githubusercontent.com/924948/211800819-60efe70a-9ebe-4bde-82e0-8fa264e8c4af.png">
<img width="427" alt="Screenshot 2023-01-11 at 12 58 36"
src="https://user-images.githubusercontent.com/924948/211800825-ae7b86d0-0e42-4227-a425-cdcd94ec78cb.png">
<img width="426" alt="Screenshot 2023-01-11 at 12 58 40"
src="https://user-images.githubusercontent.com/924948/211800827-73bfb773-b30e-495c-aa61-f5fd10f35d31.png">
<img width="428" alt="Screenshot 2023-01-11 at 12 58 46"
src="https://user-images.githubusercontent.com/924948/211800830-89539c37-7495-48f0-9de6-b7d6f15b7397.png">
<img width="427" alt="Screenshot 2023-01-11 at 12 59 03"
src="https://user-images.githubusercontent.com/924948/211800833-6f1843b9-ab22-49d9-adbd-8f5f588b52e7.png">

### Notes

#### Short URL requirement

This feature strictly requires the ShortURL service to be enabled to
work, otherwise the permalink feature is disabled for snapshot sharing.
This requirement is not clearly stated in the Share menu (yet) like
other app do as the Sharing flow had to be customised in Lens due to
some other technical challenges.
Would it be ok to workout a UI improvement as follow up?

#### Context tech debt

The way the locator works as injecting the shared state into the context
produced a discrete amount of branching, due to inconsistency of the
`context` type coming from different sources (Discover, Agg-based/TSVB,
Lens itself...). Perhaps it's worth discussing having a refactoring of
the context type here?

#### Missing locator service

Due to the way the sharing logic works in Lens the locator has not been
exported from the `plugin` functions. I thought to add a custom function
for it, but perhaps we could investigate a bit better whether this is
needed and eventually its implementation in a follow up task.

## How is the snapshot URL generated?

```mermaid
sequenceDiagram
    actor User
    User->>Share Snapshot URL: click
    Share Snapshot URL->> Lens ShortUrlService: Lens state
    Lens ShortUrlService->> Lens ShortUrlService: Check cache based on state
    Lens ShortUrlService->> ShortUrlService: Generate a Short URL
    ShortUrlService->> Lens ShortUrlService: new Short URL
    Lens ShortUrlService->> Application `getUrlForApp`: Build absolute URL
    Application `getUrlForApp`->> Lens ShortUrlService: final URL
    Lens ShortUrlService->>Share Snapshot URL: final URL
    Share Snapshot URL->>User: final URL copied in clipboard
```

### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
Co-authored-by: Andrew Tate <drewctate@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2023-01-18 18:05:26 +01:00 committed by GitHub
parent e3dff93c97
commit f78bb916ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2037 additions and 365 deletions

View file

@ -1,5 +1,81 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`shareContextMenuExtensions should render a custom panel title when provided 1`] = `
<I18nProvider>
<EuiContextMenu
data-test-subj="shareContextMenu"
initialPanelId={5}
panels={
Array [
Object {
"content": <UrlPanelContent
allowShortUrl={false}
objectType="dashboard"
urlService={Object {}}
/>,
"id": 1,
"title": "Permalink",
},
Object {
"content": <UrlPanelContent
allowShortUrl={false}
isEmbedded={true}
objectType="dashboard"
urlService={Object {}}
/>,
"id": 2,
"title": "Embed Code",
},
Object {
"content": <div>
panel content
</div>,
"id": 3,
"title": "AAA panel",
},
Object {
"content": <div>
panel content
</div>,
"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"
/>
</I18nProvider>
`;
exports[`shareContextMenuExtensions should sort ascending on sort order first and then ascending on name 1`] = `
<I18nProvider>
<EuiContextMenu
@ -35,6 +111,7 @@ exports[`shareContextMenuExtensions should sort ascending on sort order first an
"items": Array [
Object {
"data-test-subj": "sharePanel-Permalinks",
"disabled": false,
"icon": "link",
"name": "Permalinks",
"panel": 1,
@ -59,6 +136,58 @@ exports[`shareContextMenuExtensions should sort ascending on sort order first an
</I18nProvider>
`;
exports[`should disable the share URL when set 1`] = `
<I18nProvider>
<EuiContextMenu
data-test-subj="shareContextMenu"
initialPanelId={3}
panels={
Array [
Object {
"content": <UrlPanelContent
allowShortUrl={false}
objectType="dashboard"
urlService={Object {}}
/>,
"id": 1,
"title": "Permalink",
},
Object {
"content": <UrlPanelContent
allowShortUrl={false}
isEmbedded={true}
objectType="dashboard"
urlService={Object {}}
/>,
"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"
/>
</I18nProvider>
`;
exports[`should only render permalink panel when there are no other panels 1`] = `
<I18nProvider>
<EuiContextMenu
@ -119,6 +248,7 @@ exports[`should render context menu panel when there are more than one panel 1`]
},
Object {
"data-test-subj": "sharePanel-Permalinks",
"disabled": false,
"icon": "link",
"name": "Permalinks",
"panel": 1,

View file

@ -33,6 +33,11 @@ test('should only render permalink panel when there are no other panels', () =>
expect(component).toMatchSnapshot();
});
test('should disable the share URL when set', () => {
const component = shallow(<ShareContextMenu {...defaultProps} disabledShareUrl />);
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(
<ShareContextMenu
{...defaultProps}
objectTypeTitle="Custom object"
shareMenuItems={shareContextMenuItems}
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -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<ShareContextMenuProps> {
@ -64,6 +67,7 @@ export class ShareContextMenu extends Component<ShareContextMenuProps> {
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<ShareContextMenuProps> {
icon: 'link',
panel: permalinkPanel.id,
sortOrder: 0,
disabled: Boolean(this.props.disabledShareUrl),
});
panels.push(permalinkPanel);
@ -94,6 +99,7 @@ export class ShareContextMenu extends Component<ShareContextMenuProps> {
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<ShareContextMenuProps> {
title: i18n.translate('share.contextMenuTitle', {
defaultMessage: 'Share this {objectType}',
values: {
objectType: this.props.objectType,
objectType: this.props.objectTypeTitle || this.props.objectType,
},
}),
items: menuItems

View file

@ -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(
<UrlPanelContent
{...defaultProps}
objectId="id1"
allowShortUrl={false}
shareableUrlForSavedObject="socustomurl:id1#"
/>
);
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(
<UrlPanelContent {...defaultProps} allowShortUrl={false} objectId="id1" />

View file

@ -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<UrlPanelContentProps, State> {
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<UrlPanelContentProps, State> {
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);
};

View file

@ -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}
/>
</EuiWrappingPopover>
</KibanaThemeProvider>

View file

@ -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<ShareContext, 'onClose'> {
embedUrlParamExtensions?: UrlParamExtension[];
snapshotShareWarning?: string;
onClose?: () => void;
objectTypeTitle?: string;
}

View file

@ -461,6 +461,14 @@ class BrowserService extends FtrService {
await this.driver.switchTo().window(tabs[tabIndex]);
}
/**
* Opens a blank new tab.
* @return {Promise<string>}
*/
public async openNewTab() {
await this.driver.switchTo().newWindow('tab');
}
/**
* Sets a value in local storage for the focused window/frame.
*

View file

@ -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) {

View file

@ -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))"
);
});
});

View file

@ -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]);
});
});

View file

@ -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<string, unknown> & SerializableRecord;
/**
* Background search session id
*/
searchSessionId?: string;
/**
* Set the references used in the Lens state
*/
references: Array<SavedObjectReference & SerializableRecord>;
/**
* 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<string, { state: unknown }> & SerializableRecord;
/**
* Set the references used in the Lens state
*/
references?: Array<SavedObjectReference & SerializableRecord>;
/**
* Pass adHoc dataViews specs used in the Lens state
*/
dataViewSpecs?: DataViewSpec[];
}
export type LensAppLocator = LocatorPublic<LensAppLocatorParams>;
/**
* 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<string, { state: unknown }> & SerializableRecord,
references: params.references!,
};
if (params.dataViewSpecs) {
outputState.dataViewSpecs = params.dataViewSpecs;
}
return outputState;
}
export class LensAppLocatorDefinition implements LocatorDefinition<LensAppLocatorParams> {
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 },
};
};
}

View file

@ -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,7 @@ 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 },
},
};
@ -939,7 +939,47 @@ describe('Lens App', () => {
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
},
});
expect(getButton(instance).disableButton).toEqual(false);
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 },
},
};
const { instance } = await mountWith({
services,
preloadedState: {
isSaveable: true,
activeData: { layer1: { type: 'datatable', columns: [], rows: [] } },
},
});
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);
});
});

View file

@ -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<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
@ -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) && (

View file

@ -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 (
<EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareReportingForm">
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.lens.application.csvPanelContent.generationDescription"
defaultMessage="Download the data displayed in the visualization."
/>
</p>
{warnings.map((warning, i) => (
<p key={i}>{warning}</p>
))}
</EuiText>
<EuiSpacer size="s" />
<EuiButton
disabled={isDisabled}
fullWidth
fill
onClick={onClick}
data-test-subj="lnsApp_downloadCSVButton"
size="s"
>
<FormattedMessage
id="xpack.lens.application.csvPanelContent.downloadButtonLabel"
defaultMessage="Export as CSV"
/>
</EuiButton>
</EuiForm>
);
}

View file

@ -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 (
<>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</>
);
};
export const DownloadPanelContent: FC<Omit<DownloadPanelContentProps, 'intl'>> = (props) => {
return (
<Suspense fallback={<PanelSpinner />}>
<LazyComponent {...props} />
</Suspense>
);
};

View file

@ -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<string, { content: string; type: string }>;
}
}
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<Record<string, { content: string; type: string }>>(
(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: (
<DownloadPanelContent
isDisabled={!csvEnabled}
warnings={getWarnings(activeData)}
onClick={async () => {
await downloadCSVs({
title,
formatFactory: formatFactoryFn(),
activeData,
uiSettings,
});
onClose?.();
}}
/>
),
},
},
];
};
return {
id: 'csvDownload',
getShareMenuItems,
};
};

View file

@ -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 getSaveButtonMeta({
contextFromEmbeddable,
showSaveAndReturn,
showReplaceInDashboard,
showReplaceInCanvas,
}: {
contextFromEmbeddable: boolean | undefined;
showSaveAndReturn: 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: {
showSaveAndReturn: boolean;
enableExportToCSV: boolean;
showOpenInDiscover?: boolean;
showCancel: boolean;
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) {
const saveButtonMeta = getSaveButtonMeta({
showSaveAndReturn,
showReplaceInDashboard,
showReplaceInCanvas,
contextFromEmbeddable,
});
if (saveButtonMeta) {
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',
}),
...saveButtonMeta,
run: actions.saveAndReturn.execute,
disableButton: !actions.saveAndReturn.enabled,
});
}
if (showReplaceInDashboard) {
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',
}),
});
}
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<Record<string, { content: string; type: string }>>(
(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()),
}
);
}
},
showSaveModal: () => {
if (savingToDashboardPermitted || savingToLibraryPermitted) {
setIsSaveModalVisible(true);
}
},
goBack: () => {
if (contextOriginatingApp) {
goBackToOriginatingApp?.();
}
},
cancel: () => {
if (redirectToOrigin) {
redirectToOrigin();
}
},
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)
);
return discoverLocator.getRedirectUrl({
dataViewSpec: dataViews.indexPatterns[meta.id]?.spec,
timeRange: data.query.timefilter.timefilter.getTime(),
filters: newFilters,
query: isOnTextBasedMode ? query : newQuery,
columns: meta.columns,
});
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();
},
});
},
},
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,
}
);
}
},
},
showSaveModal: {
visible: Boolean(savingToDashboardPermitted || savingToLibraryPermitted),
execute: () => {
if (savingToDashboardPermitted || savingToLibraryPermitted) {
setIsSaveModalVisible(true);
}
},
},
goBack: {
visible: Boolean(contextOriginatingApp),
enabled: Boolean(contextOriginatingApp),
execute: () => {
if (contextOriginatingApp) {
goBackToOriginatingApp?.();
}
},
},
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,
});
},
},
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);

View file

@ -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<LensAppServices> {
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<unknown>; 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$}
/>

View file

@ -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<LensAppServices, 'application' | 'data'>,
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<string>,
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<SavedObjectReference & SerializableRecord>;
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<string>,
services: Pick<LensAppServices, 'application' | 'data'>,
configuration: ShareableConfiguration
) {
return {
shareableUrl: await getShortShareableURL(shortUrlService, services.data, configuration),
savedObjectURL: getShareURLForSavedObject(services, configuration.currentDoc),
};
}

View file

@ -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 },

View file

@ -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', {

View file

@ -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<CoreTheme>;
indexPatternService: IndexPatternServiceAPI;
onTextBasedSavedAndExit: ({ onSave }: { onSave: () => void }) => Promise<void>;
shortUrlService: (params: LensAppLocatorParams) => Promise<string>;
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<AvailableTopNavActions, TopNavAction>;

View file

@ -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') {

View file

@ -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}#/`),
},

View file

@ -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<LensPluginStartDependencies, void>,
@ -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,
});
},
});

View file

@ -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) &&

View file

@ -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] = {

View file

@ -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;
}

View file

@ -120,6 +120,30 @@ export async function refreshIndexPatternsList({
});
}
export function extractReferencesFromState({
activeDatasources,
datasourceStates,
visualizationState,
activeVisualization,
}: {
activeDatasources: Record<string, Datasource>;
datasourceStates: DatasourceStates;
visualizationState: unknown;
activeVisualization?: Visualization;
}): SavedObjectReference[] {
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state);
references.push(...savedObjectReferences);
});
if (activeVisualization?.getPersistableState) {
const { savedObjectReferences } = activeVisualization.getPersistableState(visualizationState);
references.push(...savedObjectReferences);
}
return references;
}
export function getIndexPatternsIds({
activeDatasources,
datasourceStates,
@ -131,19 +155,21 @@ export function getIndexPatternsIds({
visualizationState: unknown;
activeVisualization?: Visualization;
}): string[] {
let currentIndexPatternId: string | undefined;
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);
const references: SavedObjectReference[] = extractReferencesFromState({
activeDatasources,
datasourceStates,
visualizationState,
activeVisualization,
});
if (activeVisualization?.getPersistableState) {
const { savedObjectReferences } = activeVisualization.getPersistableState(visualizationState);
references.push(...savedObjectReferences);
}
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);

View file

@ -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<LensServerPluginSetup, {}, {}, {
setupSavedObjects(core, getFilterMigrations, this.customVisualizationMigrations);
setupExpressions(core, plugins.expressions);
if (plugins.share) {
plugins.share.url.locators.create(new LensAppLocatorDefinition());
}
const lensEmbeddableFactory = makeLensEmbeddableFactory(
getFilterMigrations,
DataViewPersistableStateService.getAllMigrations.bind(DataViewPersistableStateService),

View file

@ -61,6 +61,7 @@
"@kbn/monaco",
"@kbn/language-documentation-popover",
"@kbn/core-saved-objects-common",
"@kbn/core-ui-settings-browser",
],
"exclude": [
"target/**/*",

View file

@ -18271,9 +18271,7 @@
"xpack.lens.app.cancel": "Annuler",
"xpack.lens.app.cancelButtonAriaLabel": "Retour à la dernière application sans enregistrer les modifications",
"xpack.lens.app.docLoadingError": "Erreur lors du chargement du document enregistré",
"xpack.lens.app.downloadButtonAriaLabel": "Télécharger les données en fichier CSV",
"xpack.lens.app.downloadButtonFormulasWarning": "Votre fichier CSV contient des caractères que les applications de feuilles de calcul pourraient considérer comme des formules.",
"xpack.lens.app.downloadCSV": "Télécharger au format CSV",
"xpack.lens.app.exploreDataInDiscover": "Explorer les données dans Discover",
"xpack.lens.app.exploreDataInDiscoverDrilldown": "Ouvrir dans Discover",
"xpack.lens.app.exploreDataInDiscoverDrilldown.newTabConfig": "Ouvrir dans un nouvel onglet",

View file

@ -18254,9 +18254,7 @@
"xpack.lens.app.cancel": "キャンセル",
"xpack.lens.app.cancelButtonAriaLabel": "変更を保存せずに最後に使用していたアプリに戻る",
"xpack.lens.app.docLoadingError": "保存されたドキュメントの保存中にエラーが発生",
"xpack.lens.app.downloadButtonAriaLabel": "データを CSV ファイルとしてダウンロード",
"xpack.lens.app.downloadButtonFormulasWarning": "CSVには、スプレッドシートアプリケーションで式と解釈される可能性のある文字が含まれています。",
"xpack.lens.app.downloadCSV": "CSV をダウンロード",
"xpack.lens.app.exploreDataInDiscover": "Discoverでデータを探索",
"xpack.lens.app.exploreDataInDiscoverDrilldown": "Discoverで開く",
"xpack.lens.app.exploreDataInDiscoverDrilldown.newTabConfig": "新しいタブで開く",

View file

@ -18278,9 +18278,7 @@
"xpack.lens.app.cancel": "取消",
"xpack.lens.app.cancelButtonAriaLabel": "返回到上一个应用而不保存更改",
"xpack.lens.app.docLoadingError": "加载已保存文档时出错",
"xpack.lens.app.downloadButtonAriaLabel": "将数据下载为 CSV 文件",
"xpack.lens.app.downloadButtonFormulasWarning": "您的 CSV 包含电子表格应用程序可能解释为公式的字符。",
"xpack.lens.app.downloadCSV": "下载为 CSV",
"xpack.lens.app.exploreDataInDiscover": "在 Discover 中浏览数据",
"xpack.lens.app.exploreDataInDiscoverDrilldown": "在 Discover 中打开",
"xpack.lens.app.exploreDataInDiscoverDrilldown.newTabConfig": "在新选项卡中打开",

View file

@ -169,6 +169,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(metricData[0].title).to.eql('Average of bytes');
});
it('should be possible to share a URL of a visualization with adhoc dataViews', 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('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);
});
});
}

View file

@ -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');

View file

@ -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);
});
});
}

View file

@ -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'));
});
};

View file

@ -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);
});
});
}

View file

@ -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<string, { content: string; type: string }>;
}
}
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<string, { content: string; type: string }> | undefined
>(() => window.ELASTIC_LENS_CSV_CONTENT);
},
});
}