Enable right click on visualizations and dashboards listings (#88936)

* Enable right-click on visualizations listing page

* Make it simpler

* Enable right click to the dashboard listing

* Add unit tests

* Fix link on dashboard

* Fix visualize link

* Fix PR comments

* Fix functional test failure

* Use kbnUrlStateStorage instead

* Change method to getDashboardListItemLink

* Change method to getVisualizeListItemLink

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2021-01-28 15:42:47 +02:00 committed by GitHub
parent 8534faf7ee
commit b4931e6f5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 370 additions and 31 deletions

View file

@ -9,16 +9,15 @@
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import React, { Fragment, useCallback, useEffect, useMemo } from 'react';
import { attemptLoadDashboardByTitle } from '../lib';
import { DashboardAppServices, DashboardRedirect } from '../types';
import { getDashboardBreadcrumb, dashboardListingTable } from '../../dashboard_strings';
import { ApplicationStart, SavedObjectsFindOptionsReference } from '../../../../../core/public';
import { syncQueryStateWithUrl } from '../../services/data';
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
import { TableListView, useKibana } from '../../services/kibana_react';
import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss';
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
export interface DashboardListingProps {
kbnUrlStateStorage: IKbnUrlStateStorage;
@ -83,8 +82,13 @@ export const DashboardListing = ({
const tableColumns = useMemo(
() =>
getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging),
[savedObjectsTagging, redirectTo]
getTableColumns(
core.application,
kbnUrlStateStorage,
core.uiSettings.get('state:storeInSessionStorage'),
savedObjectsTagging
),
[core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging]
);
const noItemsFragment = useMemo(
@ -99,7 +103,6 @@ export const DashboardListing = ({
(filter: string) => {
let searchTerm = filter;
let references: SavedObjectsFindOptionsReference[] | undefined;
if (savedObjectsTagging) {
const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, {
useName: true,
@ -164,7 +167,9 @@ export const DashboardListing = ({
};
const getTableColumns = (
redirectTo: (id?: string) => void,
application: ApplicationStart,
kbnUrlStateStorage: IKbnUrlStateStorage,
useHash: boolean,
savedObjectsTagging?: SavedObjectsTaggingApi
) => {
return [
@ -172,9 +177,15 @@ const getTableColumns = (
field: 'title',
name: dashboardListingTable.getTitleColumnName(),
sortable: true,
render: (field: string, record: { id: string; title: string }) => (
render: (field: string, record: { id: string; title: string; timeRestore: boolean }) => (
<EuiLink
onClick={() => redirectTo(record.id)}
href={getDashboardListItemLink(
application,
kbnUrlStateStorage,
useHash,
record.id,
record.timeRestore
)}
data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}

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
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
import { ApplicationStart } from 'kibana/public';
import { esFilters } from '../../../../data/public';
import { createHashHistory } from 'history';
import { createKbnUrlStateStorage } from '../../../../kibana_utils/public';
import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator';
const DASHBOARD_ID = '13823000-99b9-11ea-9eb6-d9e8adceb647';
const application = ({
getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => {
return `/app/${appId}${options?.path}`;
}),
} as unknown) as ApplicationStart;
const history = createHashHistory();
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
});
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } });
describe('listing dashboard link', () => {
test('creates a link to a dashboard without the timerange query if time is saved on the dashboard', async () => {
const url = getDashboardListItemLink(
application,
kbnUrlStateStorage,
false,
DASHBOARD_ID,
true
);
expect(url).toMatchInlineSnapshot(`"/app/dashboards#/view/${DASHBOARD_ID}?_g=()"`);
});
test('creates a link to a dashboard with the timerange query if time is not saved on the dashboard', async () => {
const url = getDashboardListItemLink(
application,
kbnUrlStateStorage,
false,
DASHBOARD_ID,
false
);
expect(url).toMatchInlineSnapshot(
`"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:now-7d,to:now))"`
);
});
});
describe('when global time changes', () => {
beforeEach(() => {
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
time: {
from: '2021-01-05T11:45:53.375Z',
to: '2021-01-21T11:46:00.990Z',
},
});
});
test('propagates the correct time on the query', async () => {
const url = getDashboardListItemLink(
application,
kbnUrlStateStorage,
false,
DASHBOARD_ID,
false
);
expect(url).toMatchInlineSnapshot(
`"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"`
);
});
});
describe('when global refreshInterval changes', () => {
beforeEach(() => {
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
refreshInterval: { pause: false, value: 300 },
});
});
test('propagates the refreshInterval on the query', async () => {
const url = getDashboardListItemLink(
application,
kbnUrlStateStorage,
false,
DASHBOARD_ID,
false
);
expect(url).toMatchInlineSnapshot(
`"/app/dashboards#/view/${DASHBOARD_ID}?_g=(refreshInterval:(pause:!f,value:300))"`
);
});
});
describe('when global filters change', () => {
beforeEach(() => {
const filters = [
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'q1' },
},
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'q1' },
$state: {
store: esFilters.FilterStateStore.GLOBAL_STATE,
},
},
];
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
filters,
});
});
test('propagates the filters on the query', async () => {
const url = getDashboardListItemLink(
application,
kbnUrlStateStorage,
false,
DASHBOARD_ID,
false
);
expect(url).toMatchInlineSnapshot(
`"/app/dashboards#/view/${DASHBOARD_ID}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"`
);
});
});

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { ApplicationStart } from 'kibana/public';
import { QueryState } from '../../../../data/public';
import { setStateToKbnUrl } from '../../../../kibana_utils/public';
import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants';
import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator';
import { IKbnUrlStateStorage } from '../../services/kibana_utils';
export const getDashboardListItemLink = (
application: ApplicationStart,
kbnUrlStateStorage: IKbnUrlStateStorage,
useHash: boolean,
id: string,
timeRestore: boolean
) => {
let url = application.getUrlForApp(DashboardConstants.DASHBOARDS_ID, {
path: `#${createDashboardEditUrl(id)}`,
});
const globalStateInUrl = kbnUrlStateStorage.get<QueryState>(GLOBAL_STATE_STORAGE_KEY) || {};
if (timeRestore) {
delete globalStateInUrl.time;
delete globalStateInUrl.refreshInterval;
}
url = setStateToKbnUrl<QueryState>(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url);
return url;
};

View file

@ -7,3 +7,5 @@
*/
export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size';
export const STATE_STORAGE_KEY = '_a';
export const GLOBAL_STATE_STORAGE_KEY = '_g';

View file

@ -40,6 +40,7 @@ export const VisualizeListing = () => {
savedObjectsTagging,
uiSettings,
visualizeCapabilities,
kbnUrlStateStorage,
},
} = useKibana<VisualizeServices>();
const { pathname } = useLocation();
@ -94,11 +95,10 @@ export const VisualizeListing = () => {
);
const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]);
const tableColumns = useMemo(() => getTableColumns(application, history, savedObjectsTagging), [
application,
history,
savedObjectsTagging,
]);
const tableColumns = useMemo(
() => getTableColumns(application, kbnUrlStateStorage, savedObjectsTagging),
[application, kbnUrlStateStorage, savedObjectsTagging]
);
const fetchItems = useCallback(
(filter) => {

View file

@ -7,14 +7,15 @@
*/
import React from 'react';
import { History } from 'history';
import { EuiBetaBadge, EuiButton, EuiEmptyPrompt, EuiIcon, EuiLink, EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { ApplicationStart } from 'kibana/public';
import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public';
import { VisualizationListItem } from 'src/plugins/visualizations/public';
import type { SavedObjectsTaggingApi } from 'src/plugins/saved_objects_tagging_oss/public';
import { RedirectAppLinks } from '../../../../kibana_react/public';
import { getVisualizeListItemLink } from './get_visualize_list_item_link';
const getBadge = (item: VisualizationListItem) => {
if (item.stage === 'beta') {
@ -72,7 +73,7 @@ const renderItemTypeIcon = (item: VisualizationListItem) => {
export const getTableColumns = (
application: ApplicationStart,
history: History,
kbnUrlStateStorage: IKbnUrlStateStorage,
taggingApi?: SavedObjectsTaggingApi
) => [
{
@ -84,18 +85,14 @@ export const getTableColumns = (
render: (field: string, { editApp, editUrl, title, error }: VisualizationListItem) =>
// In case an error occurs i.e. the vis has wrong type, we render the vis but without the link
!error ? (
<EuiLink
onClick={() => {
if (editApp) {
application.navigateToApp(editApp, { path: editUrl });
} else if (editUrl) {
history.push(editUrl);
}
}}
data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`}
>
{field}
</EuiLink>
<RedirectAppLinks application={application}>
<EuiLink
href={getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl)}
data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`}
>
{field}
</EuiLink>
</RedirectAppLinks>
) : (
field
),

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { getVisualizeListItemLink } from './get_visualize_list_item_link';
import { ApplicationStart } from 'kibana/public';
import { createHashHistory } from 'history';
import { createKbnUrlStateStorage } from '../../../../kibana_utils/public';
import { esFilters } from '../../../../data/public';
import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants';
jest.mock('../../services', () => {
return {
getUISettings: () => ({
get: jest.fn(),
}),
};
});
const application = ({
getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => {
return `/app/${appId}${options?.path}`;
}),
} as unknown) as ApplicationStart;
const history = createHashHistory();
const kbnUrlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
});
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } });
describe('listing item link is correct for each app', () => {
test('creates a link to classic visualization if editApp is not defined', async () => {
const editUrl = 'edit/id';
const url = getVisualizeListItemLink(application, kbnUrlStateStorage, undefined, editUrl);
expect(url).toMatchInlineSnapshot(`"/app/visualize#${editUrl}?_g=(time:(from:now-7d,to:now))"`);
});
test('creates a link for the app given if editApp is defined', async () => {
const editUrl = '#/edit/id';
const editApp = 'lens';
const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl);
expect(url).toMatchInlineSnapshot(`"/app/${editApp}${editUrl}?_g=(time:(from:now-7d,to:now))"`);
});
describe('when global time changes', () => {
beforeEach(() => {
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
time: {
from: '2021-01-05T11:45:53.375Z',
to: '2021-01-21T11:46:00.990Z',
},
});
});
test('it propagates the correct time on the query', async () => {
const editUrl = '#/edit/id';
const editApp = 'lens';
const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl);
expect(url).toMatchInlineSnapshot(
`"/app/${editApp}${editUrl}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"`
);
});
});
describe('when global refreshInterval changes', () => {
beforeEach(() => {
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
refreshInterval: { pause: false, value: 300 },
});
});
test('it propagates the refreshInterval on the query', async () => {
const editUrl = '#/edit/id';
const editApp = 'lens';
const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl);
expect(url).toMatchInlineSnapshot(
`"/app/${editApp}${editUrl}?_g=(refreshInterval:(pause:!f,value:300))"`
);
});
});
describe('when global filters change', () => {
beforeEach(() => {
const filters = [
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'q1' },
},
{
meta: {
alias: null,
disabled: false,
negate: false,
},
query: { query: 'q1' },
$state: {
store: esFilters.FilterStateStore.GLOBAL_STATE,
},
},
];
kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, {
filters,
});
});
test('propagates the filters on the query', async () => {
const editUrl = '#/edit/id';
const editApp = 'lens';
const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl);
expect(url).toMatchInlineSnapshot(
`"/app/${editApp}${editUrl}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"`
);
});
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { ApplicationStart } from 'kibana/public';
import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public';
import { QueryState } from '../../../../data/public';
import { setStateToKbnUrl } from '../../../../kibana_utils/public';
import { getUISettings } from '../../services';
import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants';
import { APP_NAME } from '../visualize_constants';
export const getVisualizeListItemLink = (
application: ApplicationStart,
kbnUrlStateStorage: IKbnUrlStateStorage,
editApp: string | undefined,
editUrl: string
) => {
// for visualizations the editApp is undefined
let url = application.getUrlForApp(editApp ?? APP_NAME, {
path: editApp ? editUrl : `#${editUrl}`,
});
const useHash = getUISettings().get('state:storeInSessionStorage');
const globalStateInUrl = kbnUrlStateStorage.get<QueryState>(GLOBAL_STATE_STORAGE_KEY) || {};
url = setStateToKbnUrl<QueryState>(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url);
return url;
};

View file

@ -16,9 +16,7 @@ import {
} from '../../data/public';
import { setStateToKbnUrl } from '../../kibana_utils/public';
import { UrlGeneratorsDefinition } from '../../share/public';
const STATE_STORAGE_KEY = '_a';
const GLOBAL_STATE_STORAGE_KEY = '_g';
import { STATE_STORAGE_KEY, GLOBAL_STATE_STORAGE_KEY } from '../common/constants';
export const VISUALIZE_APP_URL_GENERATOR = 'VISUALIZE_APP_URL_GENERATOR';