[Portable Dashboards] Export Dashboard Listing Table (#154295)

export the Dashboard Listing Page component lazily. This component is a wrapper around the TableListView and can be used as-is.
This commit is contained in:
Devon Thomson 2023-04-17 16:22:06 -04:00 committed by GitHub
parent 8d9777b94f
commit b689c27ce2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1195 additions and 1375 deletions

View file

@ -6,11 +6,15 @@
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import React, { useMemo } from 'react';
import { useAsync } from 'react-use/lib';
import { Router, Redirect, Switch } from 'react-router-dom';
import { EuiSpacer } from '@elastic/eui';
import { Route } from '@kbn/shared-ux-router';
import { AppMountParameters } from '@kbn/core/public';
import { EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { DashboardListingTable } from '@kbn/dashboard-plugin/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { DualReduxExample } from './dual_redux_example';
@ -20,17 +24,70 @@ import { StaticByReferenceExample } from './static_by_reference_example';
import { DynamicByReferenceExample } from './dynamically_add_panels_example';
import { DashboardWithControlsExample } from './dashboard_with_controls_example';
const DASHBOARD_DEMO_PATH = '/dashboardDemo';
const DASHBOARD_LIST_PATH = '/listingDemo';
export const renderApp = async (
{ data, dashboard }: PortableDashboardsExampleStartDeps,
{ element }: AppMountParameters
{ element, history }: AppMountParameters
) => {
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
const findDashboardsService = await dashboard.findDashboardsService();
const logsSampleDashboardId = (await findDashboardsService?.findByTitle('[Logs] Web Traffic'))
?.id;
ReactDOM.render(
<PortableDashboardsDemos data={data} history={history} dashboard={dashboard} />,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};
const examples =
dataViews.length > 0 ? (
const PortableDashboardsDemos = ({
data,
dashboard,
history,
}: {
data: PortableDashboardsExampleStartDeps['data'];
dashboard: PortableDashboardsExampleStartDeps['dashboard'];
history: AppMountParameters['history'];
}) => {
return (
<Router history={history}>
<Switch>
<Route exact path="/">
<Redirect to={DASHBOARD_DEMO_PATH} />
</Route>
<Route path={DASHBOARD_LIST_PATH}>
<PortableDashboardListingDemo history={history} />
</Route>
<Route path={DASHBOARD_DEMO_PATH}>
<DashboardsDemo data={data} dashboard={dashboard} history={history} />
</Route>
</Switch>
</Router>
);
};
const DashboardsDemo = ({
data,
history,
dashboard,
}: {
history: AppMountParameters['history'];
data: PortableDashboardsExampleStartDeps['data'];
dashboard: PortableDashboardsExampleStartDeps['dashboard'];
}) => {
const { loading, value: dataviewResults } = useAsync(async () => {
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
const findDashboardsService = await dashboard.findDashboardsService();
const logsSampleDashboardId = (await findDashboardsService?.findByTitle('[Logs] Web Traffic'))
?.id;
return { dataViews, logsSampleDashboardId };
}, []);
const usageDemos = useMemo(() => {
if (loading || !dataviewResults) return null;
if (dataviewResults?.dataViews.length === 0) {
<div>{'Install web logs sample data to run the embeddable dashboard examples.'}</div>;
}
const { dataViews, logsSampleDashboardId } = dataviewResults;
return (
<>
<DashboardWithControlsExample dataView={dataViews[0]} />
<EuiSpacer size="xl" />
@ -42,16 +99,37 @@ export const renderApp = async (
<EuiSpacer size="xl" />
<StaticByValueExample />
</>
) : (
<div>{'Install web logs sample data to run the embeddable dashboard examples.'}</div>
);
}, [dataviewResults, loading]);
ReactDOM.render(
return (
<KibanaPageTemplate>
<KibanaPageTemplate.Header pageTitle="Portable Dashboards" />
<KibanaPageTemplate.Section>{examples}</KibanaPageTemplate.Section>
</KibanaPageTemplate>,
element
<KibanaPageTemplate.Header pageTitle="Portable dashboards usage" />
<KibanaPageTemplate.Section>
<EuiButton onClick={() => history.push(DASHBOARD_LIST_PATH)}>
View portable dashboard listing page
</EuiButton>
<EuiSpacer size="xl" />
{usageDemos}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
};
const PortableDashboardListingDemo = ({ history }: { history: AppMountParameters['history'] }) => {
return (
<DashboardListingTable
goToDashboard={(dashboardId) =>
alert(`Here's where I would redirect you to ${dashboardId ?? 'a new Dashboard'}`)
}
getDashboardUrl={() => 'https://www.elastic.co/'}
>
<EuiButton onClick={() => history.push(DASHBOARD_DEMO_PATH)}>
Go back to usage demos
</EuiButton>
<EuiSpacer size="xl" />
<EuiCallOut title="You can render something cool here" iconType="search" />
<EuiSpacer size="xl" />
</DashboardListingTable>
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -25,6 +25,7 @@
"@kbn/embeddable-examples-plugin",
"@kbn/shared-ux-page-kibana-template",
"@kbn/shared-ux-utility",
"@kbn/controls-plugin"
"@kbn/controls-plugin",
"@kbn/shared-ux-router"
]
}

View file

@ -101,136 +101,6 @@ export const getPanelTooOldErrorString = () =>
defaultMessage: 'Cannot load panels from a URL created in a version older than 7.3',
});
/*
Dashboard Listing Page
*/
export const discardConfirmStrings = {
getDiscardTitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesTitle', {
defaultMessage: 'Discard changes to dashboard?',
}),
getDiscardSubtitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', {
defaultMessage: `Once you discard your changes, there's no getting them back.`,
}),
getDiscardConfirmButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.confirmButtonLabel', {
defaultMessage: 'Discard changes',
}),
getDiscardCancelButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
export const createConfirmStrings = {
getCreateTitle: () =>
i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', {
defaultMessage: 'New dashboard already in progress',
}),
getCreateSubtitle: () =>
i18n.translate('dashboard.createConfirmModal.unsavedChangesSubtitle', {
defaultMessage: 'Continue editing or start over with a blank dashboard.',
}),
getStartOverButtonText: () =>
i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', {
defaultMessage: 'Start over',
}),
getContinueButtonText: () =>
i18n.translate('dashboard.createConfirmModal.continueButtonLabel', {
defaultMessage: 'Continue editing',
}),
getCancelButtonText: () =>
i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
export const dashboardListingErrorStrings = {
getErrorDeletingDashboardToast: () =>
i18n.translate('dashboard.deleteError.toastDescription', {
defaultMessage: 'Error encountered while deleting dashboard',
}),
};
export const dashboardListingTableStrings = {
getEntityName: () =>
i18n.translate('dashboard.listing.table.entityName', {
defaultMessage: 'dashboard',
}),
getEntityNamePlural: () =>
i18n.translate('dashboard.listing.table.entityNamePlural', {
defaultMessage: 'dashboards',
}),
getTableListTitle: () => getDashboardPageTitle(),
};
export const noItemsStrings = {
getReadonlyTitle: () =>
i18n.translate('dashboard.listing.readonlyNoItemsTitle', {
defaultMessage: 'No dashboards to view',
}),
getReadonlyBody: () =>
i18n.translate('dashboard.listing.readonlyNoItemsBody', {
defaultMessage: `There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.`,
}),
getReadEditTitle: () =>
i18n.translate('dashboard.listing.createNewDashboard.title', {
defaultMessage: 'Create your first dashboard',
}),
getReadEditInProgressTitle: () =>
i18n.translate('dashboard.listing.createNewDashboard.inProgressTitle', {
defaultMessage: 'Dashboard in progress',
}),
getReadEditDashboardDescription: () =>
i18n.translate('dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription', {
defaultMessage:
'Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.',
}),
getSampleDataLinkText: () =>
i18n.translate('dashboard.listing.createNewDashboard.sampleDataInstallLinkText', {
defaultMessage: `Add some sample data`,
}),
getCreateNewDashboardText: () =>
i18n.translate('dashboard.listing.createNewDashboard.createButtonLabel', {
defaultMessage: `Create a dashboard`,
}),
};
export const dashboardUnsavedListingStrings = {
getUnsavedChangesTitle: (plural = false) =>
i18n.translate('dashboard.listing.unsaved.unsavedChangesTitle', {
defaultMessage: 'You have unsaved changes in the following {dash}:',
values: {
dash: plural
? dashboardListingTableStrings.getEntityNamePlural()
: dashboardListingTableStrings.getEntityName(),
},
}),
getLoadingTitle: () =>
i18n.translate('dashboard.listing.unsaved.loading', {
defaultMessage: 'Loading',
}),
getEditAriaLabel: (title: string) =>
i18n.translate('dashboard.listing.unsaved.editAria', {
defaultMessage: 'Continue editing {title}',
values: { title },
}),
getEditTitle: () =>
i18n.translate('dashboard.listing.unsaved.editTitle', {
defaultMessage: 'Continue editing',
}),
getDiscardAriaLabel: (title: string) =>
i18n.translate('dashboard.listing.unsaved.discardAria', {
defaultMessage: 'Discard changes to {title}',
values: { title },
}),
getDiscardTitle: () =>
i18n.translate('dashboard.listing.unsaved.discardTitle', {
defaultMessage: 'Discard changes',
}),
};
/*
Share Modal
*/

View file

@ -15,13 +15,8 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { Switch, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom';
import { Route } from '@kbn/shared-ux-router';
import {
TableListViewKibanaDependencies,
TableListViewKibanaProvider,
} from '@kbn/content-management-table-list';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { AppMountParameters, CoreSetup } from '@kbn/core/public';
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
@ -32,14 +27,13 @@ import {
LANDING_PAGE_PATH,
VIEW_DASHBOARD_URL,
} from '../dashboard_constants';
import { DashboardListing } from './listing';
import { DashboardApp } from './dashboard_app';
import { pluginServices } from '../services/plugin_services';
import { DashboardNoMatch } from './listing/dashboard_no_match';
import { DashboardNoMatch } from './listing_page/dashboard_no_match';
import { createDashboardEditUrl } from '../dashboard_constants';
import { DashboardStart, DashboardStartDependencies } from '../plugin';
import { DashboardMountContext } from './hooks/dashboard_mount_context';
import { DashboardApplicationService } from '../services/application/types';
import { DashboardListingPage } from './listing_page/dashboard_listing_page';
import { dashboardReadonlyBadge, getDashboardPageTitle } from './_dashboard_app_strings';
import { DashboardEmbedSettings, DashboardMountContextProps, RedirectToProps } from './types';
@ -57,25 +51,15 @@ export interface DashboardMountProps {
mountContext: DashboardMountContextProps;
}
// because the type of `application.capabilities.advancedSettings` is so generic, the provider
// requiring the `save` key to be part of it is causing type issues - so, creating a custom type
type TableListViewApplicationService = DashboardApplicationService & {
capabilities: { advancedSettings: { save: boolean } };
};
export async function mountApp({ core, element, appUnMounted, mountContext }: DashboardMountProps) {
const {
application,
chrome: { setBadge, docTitle, setHelpExtension },
dashboardCapabilities: { showWriteControls },
documentationLinks: { dashboardDocLink },
settings: { uiSettings },
savedObjectsTagging,
data: dataStart,
notifications,
embeddable,
overlays,
http,
} = pluginServices.getServices();
let globalEmbedSettings: DashboardEmbedSettings | undefined;
@ -140,7 +124,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
routerHistory = routeProps.history;
}
return (
<DashboardListing
<DashboardListingPage
initialFilter={filter}
title={title}
kbnUrlStateStorage={getUrlStateStorage(routeProps.history)}
@ -170,37 +154,19 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
<I18nProvider>
<DashboardMountContext.Provider value={mountContext}>
<KibanaThemeProvider theme$={core.theme.theme$}>
<TableListViewKibanaProvider
{...{
core: {
application: application as TableListViewApplicationService,
notifications,
overlays,
http,
},
toMountPoint,
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
? ({
ui: savedObjectsTagging,
} as TableListViewKibanaDependencies['savedObjectsTagging'])
: undefined,
FormattedRelative,
}}
>
<HashRouter>
<Switch>
<Route
path={[CREATE_NEW_DASHBOARD_URL, `${VIEW_DASHBOARD_URL}/:id`]}
render={renderDashboard}
/>
<Route exact path={LANDING_PAGE_PATH} render={renderListingPage} />
<Route exact path="/">
<Redirect to={LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
</TableListViewKibanaProvider>
<HashRouter>
<Switch>
<Route
path={[CREATE_NEW_DASHBOARD_URL, `${VIEW_DASHBOARD_URL}/:id`]}
render={renderDashboard}
/>
<Route exact path={LANDING_PAGE_PATH} render={renderListingPage} />
<Route exact path="/">
<Redirect to={LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
</KibanaThemeProvider>
</DashboardMountContext.Provider>
</I18nProvider>

View file

@ -1,543 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`after fetch When given a title that matches multiple dashboards, filter on the title 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
title="search by title"
>
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
emptyPrompt={
<EuiEmptyPrompt
actions={
<EuiButton
color="primary"
data-test-subj="newItemButton"
fill={true}
iconType="plusInCircle"
onClick={[Function]}
size="m"
>
Create a dashboard
</EuiButton>
}
body={
<React.Fragment>
<p>
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
</p>
<p>
<FormattedMessage
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
values={
Object {
"sampleDataInstallLink": <EuiLink
onClick={[Function]}
>
Add some sample data
</EuiLink>,
}
}
/>
</p>
</React.Fragment>
}
iconType="dashboardApp"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
Create your first dashboard
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter="search by title"
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
exports[`after fetch initialFilter 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
>
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
emptyPrompt={
<EuiEmptyPrompt
actions={
<EuiButton
color="primary"
data-test-subj="newItemButton"
fill={true}
iconType="plusInCircle"
onClick={[Function]}
size="m"
>
Create a dashboard
</EuiButton>
}
body={
<React.Fragment>
<p>
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
</p>
<p>
<FormattedMessage
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
values={
Object {
"sampleDataInstallLink": <EuiLink
onClick={[Function]}
>
Add some sample data
</EuiLink>,
}
}
/>
</p>
</React.Fragment>
}
iconType="dashboardApp"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
Create your first dashboard
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
exports[`after fetch renders all table rows 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
>
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
emptyPrompt={
<EuiEmptyPrompt
actions={
<EuiButton
color="primary"
data-test-subj="newItemButton"
fill={true}
iconType="plusInCircle"
onClick={[Function]}
size="m"
>
Create a dashboard
</EuiButton>
}
body={
<React.Fragment>
<p>
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
</p>
<p>
<FormattedMessage
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
values={
Object {
"sampleDataInstallLink": <EuiLink
onClick={[Function]}
>
Add some sample data
</EuiLink>,
}
}
/>
</p>
</React.Fragment>
}
iconType="dashboardApp"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
Create your first dashboard
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
exports[`after fetch renders call to action when no dashboards exist 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
>
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
emptyPrompt={
<EuiEmptyPrompt
actions={
<EuiButton
color="primary"
data-test-subj="newItemButton"
fill={true}
iconType="plusInCircle"
onClick={[Function]}
size="m"
>
Create a dashboard
</EuiButton>
}
body={
<React.Fragment>
<p>
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
</p>
<p>
<FormattedMessage
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
values={
Object {
"sampleDataInstallLink": <EuiLink
onClick={[Function]}
>
Add some sample data
</EuiLink>,
}
}
/>
</p>
</React.Fragment>
}
iconType="dashboardApp"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
Create your first dashboard
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
exports[`after fetch renders call to action with continue when no dashboards exist but one is in progress 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
>
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
emptyPrompt={
<EuiEmptyPrompt
actions={
<EuiFlexGroup
alignItems="center"
gutterSize="s"
justifyContent="center"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
aria-label="Discard changes to New Dashboard"
color="danger"
data-test-subj="discardDashboardPromptButton"
onClick={[Function]}
size="s"
>
Discard changes
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
aria-label="Continue editing New Dashboard"
color="primary"
data-test-subj="newItemButton"
fill={true}
iconType="pencil"
onClick={[Function]}
size="m"
>
Continue editing
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
}
body={
<React.Fragment>
<p>
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
</p>
</React.Fragment>
}
iconType="dashboardApp"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
Dashboard in progress
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"unsavedDashboard",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
exports[`after fetch showWriteControls 1`] = `
<DashboardListing
kbnUrlStateStorage={
Object {
"cancel": [Function],
"change$": [Function],
"get": [Function],
"kbnUrlControls": Object {
"cancel": [Function],
"flush": [Function],
"getPendingUrl": [Function],
"listen": [Function],
"update": [Function],
"updateAsync": [Function],
},
"set": [Function],
}
}
redirectTo={[MockFunction]}
>
<Memo(TableListViewComp)
emptyPrompt={
<EuiEmptyPrompt
body={
<p>
There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.
</p>
}
iconType="glasses"
title={
<h1
data-test-subj="emptyListPrompt"
id="dashboardListingHeading"
>
No dashboards to view
</h1>
}
/>
}
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
tableListTitle="Dashboards"
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;

View file

@ -1,212 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import {
TableListViewKibanaDependencies,
TableListViewKibanaProvider,
} from '@kbn/content-management-table-list';
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
jest.mock('react-router-dom', () => {
return {
useLocation: () => ({
search: '',
}),
useHistory: () => ({
push: () => undefined,
}),
};
});
function makeDefaultProps(): DashboardListingProps {
return {
redirectTo: jest.fn(),
kbnUrlStateStorage: createKbnUrlStateStorage(),
};
}
function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) {
const props = incomingProps ?? makeDefaultProps();
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { application, notifications, savedObjectsTagging, http, overlays } =
pluginServices.getServices();
return (
<I18nProvider>
<TableListViewKibanaProvider
core={{
application:
application as unknown as TableListViewKibanaDependencies['core']['application'],
notifications,
http,
overlays,
}}
savedObjectsTagging={
{
ui: {
...savedObjectsTagging,
parseSearchQuery: async () => ({
searchTerm: '',
tagReferences: [],
tagReferencesToExclude: [],
}),
components: {
TagList: () => null,
},
},
} as unknown as TableListViewKibanaDependencies['savedObjectsTagging']
}
FormattedRelative={FormattedRelative}
toMountPoint={() => () => () => undefined}
>
{children}
</TableListViewKibanaProvider>
</I18nProvider>
);
};
const component = mount(<DashboardListing {...props} />, { wrappingComponent });
return { component, props };
}
describe('after fetch', () => {
test('renders all table rows', async () => {
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
});
test('renders call to action when no dashboards exist', async () => {
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findSavedObjects as jest.Mock
).mockResolvedValue({
total: 0,
hits: [],
});
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
});
test('renders call to action with continue when no dashboards exist but one is in progress', async () => {
pluginServices.getServices().dashboardSessionStorage.getDashboardIdsWithUnsavedChanges = jest
.fn()
.mockReturnValueOnce([DASHBOARD_PANELS_UNSAVED_ID])
.mockReturnValue(['dashboardUnsavedOne', 'dashboardUnsavedTwo']);
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findSavedObjects as jest.Mock
).mockResolvedValue({
total: 0,
hits: [],
});
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
});
test('initialFilter', async () => {
const props = makeDefaultProps();
props.initialFilter = 'testFilter';
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
});
test('When given a title that matches multiple dashboards, filter on the title', async () => {
const title = 'search by title';
const props = makeDefaultProps();
props.title = title;
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
).mockResolvedValue(undefined);
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props }));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
expect(props.redirectTo).not.toHaveBeenCalled();
});
test('When given a title that matches one dashboard, redirect to dashboard', async () => {
const title = 'search by title';
const props = makeDefaultProps();
props.title = title;
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
).mockResolvedValue({ id: 'you_found_me' });
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props }));
});
// Ensure the state changes are reflected
component!.update();
expect(props.redirectTo).toHaveBeenCalledWith({
destination: 'dashboard',
id: 'you_found_me',
useReplace: true,
});
});
test('showWriteControls', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
// Ensure the state changes are reflected
component!.update();
expect(component!).toMatchSnapshot();
});
});

View file

@ -1,383 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import useMount from 'react-use/lib/useMount';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiLink,
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public';
import { TableListView, type UserContentCommonSchema } from '@kbn/content-management-table-list';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { SAVED_OBJECT_DELETE_TIME, SAVED_OBJECT_LOADED_TIME } from '../../dashboard_constants';
import {
getDashboardBreadcrumb,
dashboardListingTableStrings,
noItemsStrings,
dashboardUnsavedListingStrings,
getNewDashboardTitle,
dashboardListingErrorStrings,
} from '../_dashboard_app_strings';
import {
DashboardAppNoDataPage,
isDashboardAppInNoDataState,
} from '../no_data/dashboard_app_no_data';
import { DashboardRedirect } from '../types';
import { DashboardAttributes } from '../../../common';
import { pluginServices } from '../../services/plugin_services';
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../dashboard_constants';
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
import { confirmCreateWithUnsaved, confirmDiscardUnsavedChanges } from './confirm_overlays';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
attributes: {
title: string;
description?: string;
timeRestore: boolean;
};
}
const toTableListViewSavedObject = (
savedObject: SimpleSavedObject<DashboardAttributes>
): DashboardSavedObjectUserContent => {
const { title, description, timeRestore } = savedObject.attributes;
return {
type: 'dashboard',
id: savedObject.id,
updatedAt: savedObject.updatedAt!,
references: savedObject.references,
attributes: {
title,
description,
timeRestore,
},
};
};
export interface DashboardListingProps {
kbnUrlStateStorage: IKbnUrlStateStorage;
redirectTo: DashboardRedirect;
initialFilter?: string;
title?: string;
}
export const DashboardListing = ({
title,
redirectTo,
initialFilter,
kbnUrlStateStorage,
}: DashboardListingProps) => {
const {
application,
data: { query },
dashboardSessionStorage,
settings: { uiSettings },
notifications: { toasts },
chrome: { setBreadcrumbs },
coreContext: { executionContext },
dashboardCapabilities: { showWriteControls },
dashboardSavedObject: { findDashboards, savedObjectsClient },
} = pluginServices.getServices();
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
useMount(() => {
(async () => setShowNoDataPage(await isDashboardAppInNoDataState()))();
});
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
);
useExecutionContext(executionContext, {
type: 'application',
page: 'list',
});
// Set breadcrumbs useEffect
useEffect(() => {
setBreadcrumbs([
{
text: getDashboardBreadcrumb(),
},
]);
}, [setBreadcrumbs]);
useEffect(() => {
// syncs `_g` portion of url with query services
const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl(
query,
kbnUrlStateStorage
);
if (title) {
findDashboards.findByTitle(title).then((result) => {
if (!result) return;
redirectTo({
destination: 'dashboard',
id: result.id,
useReplace: true,
});
});
}
return () => {
stopSyncingQueryServiceStateWithUrl();
};
}, [title, redirectTo, query, kbnUrlStateStorage, findDashboards]);
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
const defaultFilter = title ? `${title}` : '';
const createItem = useCallback(() => {
if (!dashboardSessionStorage.dashboardHasUnsavedEdits()) {
redirectTo({ destination: 'dashboard' });
} else {
confirmCreateWithUnsaved(
() => {
dashboardSessionStorage.clearState();
redirectTo({ destination: 'dashboard' });
},
() => redirectTo({ destination: 'dashboard' })
);
}
}, [dashboardSessionStorage, redirectTo]);
const emptyPrompt = useMemo(() => {
if (!showWriteControls) {
return (
<EuiEmptyPrompt
iconType="glasses"
title={
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
{noItemsStrings.getReadonlyTitle()}
</h1>
}
body={<p>{noItemsStrings.getReadonlyBody()}</p>}
/>
);
}
const isEditingFirstDashboard = unsavedDashboardIds.length === 1;
const emptyAction = isEditingFirstDashboard ? (
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
color="danger"
onClick={() =>
confirmDiscardUnsavedChanges(() => {
dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
})
}
data-test-subj="discardDashboardPromptButton"
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
>
{dashboardUnsavedListingStrings.getDiscardTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="pencil"
color="primary"
onClick={() => redirectTo({ destination: 'dashboard' })}
data-test-subj="newItemButton"
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
>
{dashboardUnsavedListingStrings.getEditTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiButton onClick={createItem} fill iconType="plusInCircle" data-test-subj="newItemButton">
{noItemsStrings.getCreateNewDashboardText()}
</EuiButton>
);
return (
<EuiEmptyPrompt
iconType="dashboardApp"
title={
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
{isEditingFirstDashboard
? noItemsStrings.getReadEditInProgressTitle()
: noItemsStrings.getReadEditTitle()}
</h1>
}
body={
<>
<p>{noItemsStrings.getReadEditDashboardDescription()}</p>
{!isEditingFirstDashboard && (
<p>
<FormattedMessage
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
values={{
sampleDataInstallLink: (
<EuiLink
onClick={() =>
application.navigateToApp('home', {
path: '#/tutorial_directory/sampleData',
})
}
>
{noItemsStrings.getSampleDataLinkText()}
</EuiLink>
),
}}
/>
</p>
)}
</>
}
actions={emptyAction}
/>
);
}, [
redirectTo,
createItem,
application,
showWriteControls,
unsavedDashboardIds,
dashboardSessionStorage,
]);
const fetchItems = useCallback(
(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
const searchStartTime = window.performance.now();
return findDashboards
.findSavedObjects({
search: searchTerm,
size: listingLimit,
hasReference: references,
hasNoReference: referencesToExclude,
})
.then(({ total, hits }) => {
const searchEndTime = window.performance.now();
const searchDuration = searchEndTime - searchStartTime;
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
eventName: SAVED_OBJECT_LOADED_TIME,
duration: searchDuration,
meta: {
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
},
});
return {
total,
hits: hits.map(toTableListViewSavedObject),
};
});
},
[findDashboards, listingLimit]
);
const deleteItems = useCallback(
async (dashboardsToDelete: Array<{ id: string }>) => {
try {
const deleteStartTime = window.performance.now();
await Promise.all(
dashboardsToDelete.map(({ id }) => {
dashboardSessionStorage.clearState(id);
return savedObjectsClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id);
})
);
const deleteDuration = window.performance.now() - deleteStartTime;
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
eventName: SAVED_OBJECT_DELETE_TIME,
duration: deleteDuration,
meta: {
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
total: dashboardsToDelete.length,
},
});
} catch (error) {
toasts.addError(error, {
title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(),
});
}
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
},
[savedObjectsClient, dashboardSessionStorage, toasts]
);
const editItem = useCallback(
({ id }: { id: string | undefined }) =>
redirectTo({ destination: 'dashboard', id, editMode: true }),
[redirectTo]
);
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
return (
<>
{showNoDataPage && (
<DashboardAppNoDataPage onDataViewCreated={() => setShowNoDataPage(false)} />
)}
{!showNoDataPage && (
<TableListView<DashboardSavedObjectUserContent>
createItem={!showWriteControls ? undefined : createItem}
deleteItems={!showWriteControls ? undefined : deleteItems}
initialPageSize={initialPageSize}
editItem={!showWriteControls ? undefined : editItem}
initialFilter={initialFilter ?? defaultFilter}
headingId="dashboardListingHeading"
findItems={fetchItems}
entityNamePlural={getEntityNamePlural()}
tableListTitle={getTableListTitle()}
entityName={getEntityName()}
{...{
emptyPrompt,
listingLimit,
}}
id="dashboard"
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore)
}
>
<DashboardUnsavedListing
redirectTo={redirectTo}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={() =>
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
}
/>
</TableListView>
)}
</>
);
};

View file

@ -1,9 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { DashboardListing } from './dashboard_listing';

View file

@ -0,0 +1,127 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { DashboardListingPage, DashboardListingPageProps } from './dashboard_listing_page';
// Mock child components. The Dashboard listing page mostly passes down props to shared UX components which are tested in their own packages.
import { DashboardListing } from '../../dashboard_listing/dashboard_listing';
jest.mock('../../dashboard_listing/dashboard_listing', () => {
return {
__esModule: true,
DashboardListing: jest.fn().mockReturnValue(null),
};
});
import { DashboardAppNoDataPage } from '../no_data/dashboard_app_no_data';
jest.mock('../no_data/dashboard_app_no_data', () => {
const originalModule = jest.requireActual('../no_data/dashboard_app_no_data');
return {
__esModule: true,
...originalModule,
DashboardAppNoDataPage: jest.fn().mockReturnValue(null),
};
});
function makeDefaultProps(): DashboardListingPageProps {
return {
redirectTo: jest.fn(),
kbnUrlStateStorage: createKbnUrlStateStorage(),
};
}
function mountWith({ props: incomingProps }: { props?: DashboardListingPageProps }) {
const props = incomingProps ?? makeDefaultProps();
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
return <I18nProvider>{children}</I18nProvider>;
};
const component = mount(<DashboardListingPage {...props} />, { wrappingComponent });
return { component, props };
}
test('renders analytics no data page when the user has no data view', async () => {
pluginServices.getServices().data.dataViews.hasData.hasUserDataView = jest
.fn()
.mockResolvedValue(false);
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
component!.update();
expect(DashboardAppNoDataPage).toHaveBeenCalled();
});
test('initialFilter is passed through if title is not provided', async () => {
const props = makeDefaultProps();
props.initialFilter = 'filterPassThrough';
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props }));
});
component!.update();
expect(DashboardListing).toHaveBeenCalledWith(
expect.objectContaining({ initialFilter: 'filterPassThrough' }),
expect.any(Object) // react context
);
});
test('When given a title that matches multiple dashboards, filter on the title', async () => {
const title = 'search by title';
const props = makeDefaultProps();
props.title = title;
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
).mockResolvedValue(undefined);
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props }));
});
component!.update();
expect(props.redirectTo).not.toHaveBeenCalled();
expect(DashboardListing).toHaveBeenCalledWith(
expect.objectContaining({ initialFilter: 'search by title' }),
expect.any(Object) // react context
);
});
test('When given a title that matches one dashboard, redirect to dashboard', async () => {
const title = 'search by title';
const props = makeDefaultProps();
props.title = title;
(
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
).mockResolvedValue({ id: 'you_found_me' });
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props }));
});
component!.update();
expect(props.redirectTo).toHaveBeenCalledWith({
destination: 'dashboard',
id: 'you_found_me',
useReplace: true,
});
});

View file

@ -0,0 +1,107 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import {
DashboardAppNoDataPage,
isDashboardAppInNoDataState,
} from '../no_data/dashboard_app_no_data';
import { DashboardRedirect } from '../types';
import { pluginServices } from '../../services/plugin_services';
import { getDashboardBreadcrumb } from '../_dashboard_app_strings';
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
import { DashboardListing } from '../../dashboard_listing/dashboard_listing';
export interface DashboardListingPageProps {
kbnUrlStateStorage: IKbnUrlStateStorage;
redirectTo: DashboardRedirect;
initialFilter?: string;
title?: string;
}
export const DashboardListingPage = ({
title,
redirectTo,
initialFilter,
kbnUrlStateStorage,
}: DashboardListingPageProps) => {
const {
data: { query },
chrome: { setBreadcrumbs },
dashboardSavedObject: { findDashboards },
} = pluginServices.getServices();
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
useEffect(() => {
let isMounted = true;
(async () => {
const isInNoDataState = await isDashboardAppInNoDataState();
if (isInNoDataState && isMounted) setShowNoDataPage(true);
})();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
setBreadcrumbs([
{
text: getDashboardBreadcrumb(),
},
]);
}, [setBreadcrumbs]);
useEffect(() => {
// syncs `_g` portion of url with query services
const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl(
query,
kbnUrlStateStorage
);
if (title) {
findDashboards.findByTitle(title).then((result) => {
if (!result) return;
redirectTo({
destination: 'dashboard',
id: result.id,
useReplace: true,
});
});
}
return () => {
stopSyncingQueryServiceStateWithUrl();
};
}, [title, redirectTo, query, kbnUrlStateStorage, findDashboards]);
const titleFilter = title ? `${title}` : '';
return (
<>
{showNoDataPage && (
<DashboardAppNoDataPage onDataViewCreated={() => setShowNoDataPage(false)} />
)}
{!showNoDataPage && (
<DashboardListing
useSessionStorageIntegration={true}
initialFilter={initialFilter ?? titleFilter}
goToDashboard={(id, viewMode) => {
redirectTo({ destination: 'dashboard', id, editMode: viewMode === ViewMode.EDIT });
}}
getDashboardUrl={(id, timeRestore) => {
return getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore);
}}
/>
)}
</>
);
};

View file

@ -19,8 +19,8 @@ import { ShowShareModal } from './share/show_share_modal';
import { pluginServices } from '../../services/plugin_services';
import { CHANGE_CHECK_DEBOUNCE } from '../../dashboard_constants';
import { SaveDashboardReturn } from '../../services/dashboard_saved_object/types';
import { confirmDiscardUnsavedChanges } from '../../dashboard_listing/confirm_overlays';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_context';
import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays';
export const useDashboardMenuItems = ({
redirectTo,

View file

@ -0,0 +1,144 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const dashboardListingErrorStrings = {
getErrorDeletingDashboardToast: () =>
i18n.translate('dashboard.deleteError.toastDescription', {
defaultMessage: 'Error encountered while deleting dashboard',
}),
};
export const getNewDashboardTitle = () =>
i18n.translate('dashboard.listing.newDashboardTitle', {
defaultMessage: 'New Dashboard',
});
export const dashboardListingTableStrings = {
getEntityName: () =>
i18n.translate('dashboard.listing.table.entityName', {
defaultMessage: 'dashboard',
}),
getEntityNamePlural: () =>
i18n.translate('dashboard.listing.table.entityNamePlural', {
defaultMessage: 'dashboards',
}),
getTableListTitle: () =>
i18n.translate('dashboard.listing.tableListTitle', {
defaultMessage: 'Dashboards',
}),
};
export const noItemsStrings = {
getReadonlyTitle: () =>
i18n.translate('dashboard.listing.readonlyNoItemsTitle', {
defaultMessage: 'No dashboards to view',
}),
getReadonlyBody: () =>
i18n.translate('dashboard.listing.readonlyNoItemsBody', {
defaultMessage: `There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.`,
}),
getReadEditTitle: () =>
i18n.translate('dashboard.listing.createNewDashboard.title', {
defaultMessage: 'Create your first dashboard',
}),
getReadEditInProgressTitle: () =>
i18n.translate('dashboard.listing.createNewDashboard.inProgressTitle', {
defaultMessage: 'Dashboard in progress',
}),
getReadEditDashboardDescription: () =>
i18n.translate('dashboard.listing.createNewDashboard.combineDataViewFromKibanaAppDescription', {
defaultMessage:
'Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.',
}),
getSampleDataLinkText: () =>
i18n.translate('dashboard.listing.createNewDashboard.sampleDataInstallLinkText', {
defaultMessage: `Add some sample data`,
}),
getCreateNewDashboardText: () =>
i18n.translate('dashboard.listing.createNewDashboard.createButtonLabel', {
defaultMessage: `Create a dashboard`,
}),
};
export const dashboardUnsavedListingStrings = {
getUnsavedChangesTitle: (plural = false) =>
i18n.translate('dashboard.listing.unsaved.unsavedChangesTitle', {
defaultMessage: 'You have unsaved changes in the following {dash}:',
values: {
dash: plural
? dashboardListingTableStrings.getEntityNamePlural()
: dashboardListingTableStrings.getEntityName(),
},
}),
getLoadingTitle: () =>
i18n.translate('dashboard.listing.unsaved.loading', {
defaultMessage: 'Loading',
}),
getEditAriaLabel: (title: string) =>
i18n.translate('dashboard.listing.unsaved.editAria', {
defaultMessage: 'Continue editing {title}',
values: { title },
}),
getEditTitle: () =>
i18n.translate('dashboard.listing.unsaved.editTitle', {
defaultMessage: 'Continue editing',
}),
getDiscardAriaLabel: (title: string) =>
i18n.translate('dashboard.listing.unsaved.discardAria', {
defaultMessage: 'Discard changes to {title}',
values: { title },
}),
getDiscardTitle: () =>
i18n.translate('dashboard.listing.unsaved.discardTitle', {
defaultMessage: 'Discard changes',
}),
};
export const discardConfirmStrings = {
getDiscardTitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesTitle', {
defaultMessage: 'Discard changes to dashboard?',
}),
getDiscardSubtitle: () =>
i18n.translate('dashboard.discardChangesConfirmModal.discardChangesDescription', {
defaultMessage: `Once you discard your changes, there's no getting them back.`,
}),
getDiscardConfirmButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.confirmButtonLabel', {
defaultMessage: 'Discard changes',
}),
getDiscardCancelButtonText: () =>
i18n.translate('dashboard.discardChangesConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};
export const createConfirmStrings = {
getCreateTitle: () =>
i18n.translate('dashboard.createConfirmModal.unsavedChangesTitle', {
defaultMessage: 'New dashboard already in progress',
}),
getCreateSubtitle: () =>
i18n.translate('dashboard.createConfirmModal.unsavedChangesSubtitle', {
defaultMessage: 'Continue editing or start over with a blank dashboard.',
}),
getStartOverButtonText: () =>
i18n.translate('dashboard.createConfirmModal.confirmButtonLabel', {
defaultMessage: 'Start over',
}),
getContinueButtonText: () =>
i18n.translate('dashboard.createConfirmModal.continueButtonLabel', {
defaultMessage: 'Continue editing',
}),
getCancelButtonText: () =>
i18n.translate('dashboard.createConfirmModal.cancelButtonLabel', {
defaultMessage: 'Cancel',
}),
};

View file

@ -22,8 +22,8 @@ import {
} from '@elastic/eui';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { createConfirmStrings, discardConfirmStrings } from '../_dashboard_app_strings';
import { pluginServices } from '../services/plugin_services';
import { createConfirmStrings, discardConfirmStrings } from './_dashboard_listing_strings';
export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import { pluginServices } from '../services/plugin_services';
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
/**
* Mock Table List view. This dashboard component is a wrapper around the shared UX table List view. We
* need to ensure we're passing down the correct props, but the table list view itself doesn't need to be rendered
* in our tests because it is covered in its package.
*/
import { TableListView } from '@kbn/content-management-table-list';
// import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
jest.mock('@kbn/content-management-table-list', () => {
const originalModule = jest.requireActual('@kbn/content-management-table-list');
return {
__esModule: true,
...originalModule,
TableListViewKibanaProvider: jest.fn().mockImplementation(({ children }) => {
return <>{children}</>;
}),
TableListView: jest.fn().mockReturnValue(null),
};
});
function makeDefaultProps(): DashboardListingProps {
return {
goToDashboard: jest.fn(),
getDashboardUrl: jest.fn(),
};
}
function mountWith({ props: incomingProps }: { props?: Partial<DashboardListingProps> }) {
const props = { ...makeDefaultProps(), ...incomingProps };
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
return <I18nProvider>{children}</I18nProvider>;
};
const component = mount(<DashboardListing {...props} />, { wrappingComponent });
return { component, props };
}
test('initial filter is passed through', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props: { initialFilter: 'kibanana' } }));
});
component!.update();
expect(TableListView).toHaveBeenCalledWith(
expect.objectContaining({ initialFilter: 'kibanana' }),
expect.any(Object) // react context
);
});
test('when showWriteControls is true, table list view is passed editing functions', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
component!.update();
expect(TableListView).toHaveBeenCalledWith(
expect.objectContaining({
createItem: expect.any(Function),
deleteItems: expect.any(Function),
editItem: expect.any(Function),
}),
expect.any(Object) // react context
);
});
test('when showWriteControls is false, table list view is not passed editing functions', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
component!.update();
expect(TableListView).toHaveBeenCalledWith(
expect.objectContaining({
createItem: undefined,
deleteItems: undefined,
editItem: undefined,
}),
expect.any(Object) // react context
);
});

View file

@ -0,0 +1,264 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
import React, { PropsWithChildren, useCallback, useState } from 'react';
import {
TableListView,
TableListViewKibanaDependencies,
TableListViewKibanaProvider,
type UserContentCommonSchema,
} from '@kbn/content-management-table-list';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public';
import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public';
import {
SAVED_OBJECT_DELETE_TIME,
SAVED_OBJECT_LOADED_TIME,
DASHBOARD_SAVED_OBJECT_TYPE,
} from '../dashboard_constants';
import {
dashboardListingTableStrings,
dashboardListingErrorStrings,
} from './_dashboard_listing_strings';
import { DashboardAttributes } from '../../common';
import { pluginServices } from '../services/plugin_services';
import { confirmCreateWithUnsaved } from './confirm_overlays';
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
import { DashboardApplicationService } from '../services/application/types';
import { DashboardListingEmptyPrompt } from './dashboard_listing_empty_prompt';
// because the type of `application.capabilities.advancedSettings` is so generic, the provider
// requiring the `save` key to be part of it is causing type issues - so, creating a custom type
type TableListViewApplicationService = DashboardApplicationService & {
capabilities: { advancedSettings: { save: boolean } };
};
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
attributes: {
title: string;
description?: string;
timeRestore: boolean;
};
}
const toTableListViewSavedObject = (
savedObject: SimpleSavedObject<DashboardAttributes>
): DashboardSavedObjectUserContent => {
const { title, description, timeRestore } = savedObject.attributes;
return {
type: 'dashboard',
id: savedObject.id,
updatedAt: savedObject.updatedAt!,
references: savedObject.references,
attributes: {
title,
description,
timeRestore,
},
};
};
export type DashboardListingProps = PropsWithChildren<{
initialFilter?: string;
useSessionStorageIntegration?: boolean;
goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void;
getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string;
}>;
export const DashboardListing = ({
children,
initialFilter,
goToDashboard,
getDashboardUrl,
useSessionStorageIntegration,
}: DashboardListingProps) => {
const {
application,
notifications,
overlays,
http,
savedObjectsTagging,
dashboardSessionStorage,
settings: { uiSettings },
notifications: { toasts },
coreContext: { executionContext },
dashboardCapabilities: { showWriteControls },
dashboardSavedObject: { findDashboards, savedObjectsClient },
} = pluginServices.getServices();
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
);
useExecutionContext(executionContext, {
type: 'application',
page: 'list',
});
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
const createItem = useCallback(() => {
if (useSessionStorageIntegration && dashboardSessionStorage.dashboardHasUnsavedEdits()) {
confirmCreateWithUnsaved(() => {
dashboardSessionStorage.clearState();
goToDashboard();
}, goToDashboard);
return;
}
goToDashboard();
}, [dashboardSessionStorage, goToDashboard, useSessionStorageIntegration]);
const fetchItems = useCallback(
(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
const searchStartTime = window.performance.now();
return findDashboards
.findSavedObjects({
search: searchTerm,
size: listingLimit,
hasReference: references,
hasNoReference: referencesToExclude,
})
.then(({ total, hits }) => {
const searchEndTime = window.performance.now();
const searchDuration = searchEndTime - searchStartTime;
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
eventName: SAVED_OBJECT_LOADED_TIME,
duration: searchDuration,
meta: {
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
},
});
return {
total,
hits: hits.map(toTableListViewSavedObject),
};
});
},
[findDashboards, listingLimit]
);
const deleteItems = useCallback(
async (dashboardsToDelete: Array<{ id: string }>) => {
try {
const deleteStartTime = window.performance.now();
await Promise.all(
dashboardsToDelete.map(({ id }) => {
dashboardSessionStorage.clearState(id);
return savedObjectsClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id);
})
);
const deleteDuration = window.performance.now() - deleteStartTime;
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
eventName: SAVED_OBJECT_DELETE_TIME,
duration: deleteDuration,
meta: {
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
total: dashboardsToDelete.length,
},
});
} catch (error) {
toasts.addError(error, {
title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(),
});
}
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
},
[savedObjectsClient, dashboardSessionStorage, toasts]
);
const editItem = useCallback(
({ id }: { id: string | undefined }) => goToDashboard(id, ViewMode.EDIT),
[goToDashboard]
);
const emptyPrompt = (
<DashboardListingEmptyPrompt
createItem={createItem}
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
setUnsavedDashboardIds={setUnsavedDashboardIds}
useSessionStorageIntegration={useSessionStorageIntegration}
/>
);
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
return (
<I18nProvider>
<TableListViewKibanaProvider
{...{
core: {
application: application as TableListViewApplicationService,
notifications,
overlays,
http,
},
toMountPoint,
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
? ({
ui: savedObjectsTagging,
} as TableListViewKibanaDependencies['savedObjectsTagging'])
: undefined,
FormattedRelative,
}}
>
<TableListView<DashboardSavedObjectUserContent>
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
getDashboardUrl(id, timeRestore)
}
deleteItems={!showWriteControls ? undefined : deleteItems}
createItem={!showWriteControls ? undefined : createItem}
editItem={!showWriteControls ? undefined : editItem}
entityNamePlural={getEntityNamePlural()}
tableListTitle={getTableListTitle()}
headingId="dashboardListingHeading"
initialPageSize={initialPageSize}
initialFilter={initialFilter}
entityName={getEntityName()}
listingLimit={listingLimit}
emptyPrompt={emptyPrompt}
findItems={fetchItems}
id="dashboard"
>
<>
{children}
<DashboardUnsavedListing
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={() =>
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
}
/>
</>
</TableListView>
</TableListViewKibanaProvider>
</I18nProvider>
);
};
// eslint-disable-next-line import/no-default-export
export default DashboardListing;

View file

@ -0,0 +1,111 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import {
DashboardListingEmptyPrompt,
DashboardListingEmptyPromptProps,
} from './dashboard_listing_empty_prompt';
import { pluginServices } from '../services/plugin_services';
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
jest.mock('./confirm_overlays', () => {
const originalModule = jest.requireActual('./confirm_overlays');
return {
__esModule: true,
...originalModule,
confirmDiscardUnsavedChanges: jest.fn(),
};
});
const makeDefaultProps = (): DashboardListingEmptyPromptProps => ({
createItem: jest.fn(),
unsavedDashboardIds: [],
goToDashboard: jest.fn(),
setUnsavedDashboardIds: jest.fn(),
useSessionStorageIntegration: true,
});
function mountWith({
props: incomingProps,
}: {
props?: Partial<DashboardListingEmptyPromptProps>;
}) {
const props = { ...makeDefaultProps(), ...incomingProps };
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
return <I18nProvider>{children}</I18nProvider>;
};
const component = mount(<DashboardListingEmptyPrompt {...props} />, { wrappingComponent });
return { component, props };
}
test('renders readonly empty prompt when showWriteControls is off', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
component!.update();
expect(component!.find('EuiLink').length).toBe(0);
});
test('renders empty prompt with link when showWriteControls is on', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({}));
});
component!.update();
expect(component!.find('EuiLink').length).toBe(1);
});
test('renders continue button when no dashboards exist but one is in progress', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
let component: ReactWrapper;
let props: DashboardListingEmptyPromptProps;
await act(async () => {
({ component, props } = mountWith({
props: { unsavedDashboardIds: ['newDashboard'], useSessionStorageIntegration: true },
}));
});
component!.update();
await act(async () => {
// EuiButton is used for the Continue button
const continueButton = component!.find('EuiButton');
expect(continueButton.length).toBe(1);
continueButton.find('button').simulate('click');
});
expect(props!.goToDashboard).toHaveBeenCalled();
});
test('renders discard button when no dashboards exist but one is in progress', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({
props: { unsavedDashboardIds: ['coolId'], useSessionStorageIntegration: true },
}));
});
component!.update();
await act(async () => {
// EuiButtonEmpty is used for the discard button
component!.find('EuiButtonEmpty').simulate('click');
});
expect(confirmDiscardUnsavedChanges).toHaveBeenCalled();
});

View file

@ -0,0 +1,157 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiLink,
EuiButton,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiEmptyPrompt,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useMemo } from 'react';
import {
noItemsStrings,
getNewDashboardTitle,
dashboardUnsavedListingStrings,
} from './_dashboard_listing_strings';
import { DashboardListingProps } from './dashboard_listing';
import { pluginServices } from '../services/plugin_services';
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service';
export interface DashboardListingEmptyPromptProps {
createItem: () => void;
unsavedDashboardIds: string[];
goToDashboard: DashboardListingProps['goToDashboard'];
setUnsavedDashboardIds: React.Dispatch<React.SetStateAction<string[]>>;
useSessionStorageIntegration: DashboardListingProps['useSessionStorageIntegration'];
}
export const DashboardListingEmptyPrompt = ({
useSessionStorageIntegration,
setUnsavedDashboardIds,
unsavedDashboardIds,
goToDashboard,
createItem,
}: DashboardListingEmptyPromptProps) => {
const {
application,
dashboardSessionStorage,
dashboardCapabilities: { showWriteControls },
} = pluginServices.getServices();
const isEditingFirstDashboard = useMemo(
() => useSessionStorageIntegration && unsavedDashboardIds.length === 1,
[unsavedDashboardIds.length, useSessionStorageIntegration]
);
const getEmptyAction = useCallback(() => {
if (!isEditingFirstDashboard) {
return (
<EuiButton onClick={createItem} fill iconType="plusInCircle" data-test-subj="newItemButton">
{noItemsStrings.getCreateNewDashboardText()}
</EuiButton>
);
}
return (
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
color="danger"
onClick={() =>
confirmDiscardUnsavedChanges(() => {
dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
})
}
data-test-subj="discardDashboardPromptButton"
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
>
{dashboardUnsavedListingStrings.getDiscardTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="pencil"
color="primary"
data-test-subj="newItemButton"
onClick={() => goToDashboard()}
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
>
{dashboardUnsavedListingStrings.getEditTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [
dashboardSessionStorage,
isEditingFirstDashboard,
setUnsavedDashboardIds,
goToDashboard,
createItem,
]);
if (!showWriteControls) {
return (
<EuiEmptyPrompt
iconType="glasses"
title={
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
{noItemsStrings.getReadonlyTitle()}
</h1>
}
body={<p>{noItemsStrings.getReadonlyBody()}</p>}
/>
);
}
return (
<EuiEmptyPrompt
iconType="dashboardApp"
title={
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
{isEditingFirstDashboard
? noItemsStrings.getReadEditInProgressTitle()
: noItemsStrings.getReadEditTitle()}
</h1>
}
body={
<>
<p>{noItemsStrings.getReadEditDashboardDescription()}</p>
{!isEditingFirstDashboard && (
<p>
<FormattedMessage
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
values={{
sampleDataInstallLink: (
<EuiLink
onClick={() =>
application.navigateToApp('home', {
path: '#/tutorial_directory/sampleData',
})
}
>
{noItemsStrings.getSampleDataLinkText()}
</EuiLink>
),
}}
/>
</p>
)}
</>
}
actions={getEmptyAction()}
/>
);
};

View file

@ -12,18 +12,19 @@ import { I18nProvider } from '@kbn/i18n-react';
import { waitFor } from '@testing-library/react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { pluginServices } from '../services/plugin_services';
import { DashboardUnsavedListing, DashboardUnsavedListingProps } from './dashboard_unsaved_listing';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
import { pluginServices } from '../../services/plugin_services';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service';
import { ViewMode } from '@kbn/embeddable-plugin/public';
const makeDefaultProps = (): DashboardUnsavedListingProps => ({
redirectTo: jest.fn(),
goToDashboard: jest.fn(),
unsavedDashboardIds: ['dashboardUnsavedOne', 'dashboardUnsavedTwo', 'dashboardUnsavedThree'],
refreshUnsavedDashboards: jest.fn(),
});
function mountWith({ props: incomingProps }: { props?: DashboardUnsavedListingProps }) {
const props = incomingProps ?? makeDefaultProps();
function mountWith({ props: incomingProps }: { props?: Partial<DashboardUnsavedListingProps> }) {
const props = { ...makeDefaultProps(), ...incomingProps };
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
@ -62,11 +63,7 @@ describe('Unsaved listing', () => {
expect(getEditButton().length).toEqual(1);
});
getEditButton().simulate('click');
expect(props.redirectTo).toHaveBeenCalledWith({
destination: 'dashboard',
id: 'dashboardUnsavedOne',
editMode: true,
});
expect(props.goToDashboard).toHaveBeenCalledWith('dashboardUnsavedOne', ViewMode.EDIT);
});
it('Redirects to new dashboard when continue editing clicked', async () => {
@ -79,11 +76,7 @@ describe('Unsaved listing', () => {
expect(getEditButton().length).toBe(1);
});
getEditButton().simulate('click');
expect(props.redirectTo).toHaveBeenCalledWith({
destination: 'dashboard',
id: undefined,
editMode: true,
});
expect(props.goToDashboard).toHaveBeenCalledWith(undefined, ViewMode.EDIT);
});
it('Shows a warning then clears changes when delete unsaved changes is pressed', async () => {

View file

@ -17,12 +17,13 @@ import {
} from '@elastic/eui';
import React, { useCallback, useEffect, useState } from 'react';
import type { DashboardRedirect } from '../types';
import { DashboardAttributes } from '../../../common';
import { pluginServices } from '../../services/plugin_services';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardAttributes } from '../../common';
import { pluginServices } from '../services/plugin_services';
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
import { dashboardUnsavedListingStrings, getNewDashboardTitle } from '../_dashboard_app_strings';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
import { dashboardUnsavedListingStrings, getNewDashboardTitle } from './_dashboard_listing_strings';
import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service';
const DashboardUnsavedItem = ({
id,
@ -104,13 +105,13 @@ interface UnsavedItemMap {
}
export interface DashboardUnsavedListingProps {
refreshUnsavedDashboards: () => void;
redirectTo: DashboardRedirect;
unsavedDashboardIds: string[];
refreshUnsavedDashboards: () => void;
goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void;
}
export const DashboardUnsavedListing = ({
redirectTo,
goToDashboard,
unsavedDashboardIds,
refreshUnsavedDashboards,
}: DashboardUnsavedListingProps) => {
@ -123,9 +124,9 @@ export const DashboardUnsavedListing = ({
const onOpen = useCallback(
(id?: string) => {
redirectTo({ destination: 'dashboard', id, editMode: true });
goToDashboard(id, ViewMode.EDIT);
},
[redirectTo]
[goToDashboard]
);
const onDiscard = useCallback(

View file

@ -0,0 +1,36 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Suspense } from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
import { servicesReady } from '../plugin';
import { DashboardListingProps } from './dashboard_listing';
const ListingTableLoadingIndicator = () => {
return <EuiEmptyPrompt icon={<EuiLoadingSpinner size="l" />} />;
};
const LazyDashboardListing = React.lazy(() =>
(async () => {
const modulePromise = import('./dashboard_listing');
const [module] = await Promise.all([modulePromise, servicesReady]);
return {
default: module.DashboardListing,
};
})().then((module) => module)
);
export const DashboardListingTable = (props: DashboardListingProps) => {
return (
<Suspense fallback={<ListingTableLoadingIndicator />}>
<LazyDashboardListing {...props} />
</Suspense>
);
};

View file

@ -23,6 +23,8 @@ export {
} from './dashboard_container';
export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin';
export { DashboardListingTable } from './dashboard_listing';
export {
type DashboardAppLocator,
type DashboardAppLocatorParams,

View file

@ -62,8 +62,8 @@ import {
LEGACY_DASHBOARD_APP_ID,
SEARCH_SESSION_ID,
} from './dashboard_constants';
import { PlaceholderEmbeddableFactory } from './placeholder_embeddable';
import { DashboardMountContextProps } from './dashboard_app/types';
import { PlaceholderEmbeddableFactory } from './placeholder_embeddable';
import type { FindDashboardsService } from './services/dashboard_saved_object/types';
export interface DashboardFeatureFlagConfig {
@ -114,6 +114,9 @@ export interface DashboardStart {
findDashboardsService: () => Promise<FindDashboardsService>;
}
export let resolveServicesReady: () => void;
export const servicesReady = new Promise<void>((resolve) => (resolveServicesReady = resolve));
export class DashboardPlugin
implements
Plugin<DashboardSetup, DashboardStart, DashboardSetupDependencies, DashboardStartDependencies>
@ -133,6 +136,7 @@ export class DashboardPlugin
) {
const { registry, pluginServices } = await import('./services/plugin_services');
pluginServices.setRegistry(registry.start({ coreStart, startPlugins, initContext }));
resolveServicesReady();
}
public setup(

View file

@ -37,6 +37,7 @@ export const dashboardSavedObjectServiceFactory: DashboardSavedObjectServiceFact
description: `dashboard${i} desc`,
title: `dashboard${i} - ${search} - title`,
},
references: [] as FindDashboardSavedObjectsResponse['hits'][0]['references'],
} as FindDashboardSavedObjectsResponse['hits'][0]);
}
return Promise.resolve({