From b689c27ce21f918c01809a4067a886ec6db99591 Mon Sep 17 00:00:00 2001
From: Devon Thomson
Date: Mon, 17 Apr 2023 16:22:06 -0400
Subject: [PATCH] [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.
---
.../public/app.tsx | 112 +++-
.../portable_dashboards_example/tsconfig.json | 3 +-
.../dashboard_app/_dashboard_app_strings.ts | 130 -----
.../public/dashboard_app/dashboard_router.tsx | 68 +--
.../dashboard_listing.test.tsx.snap | 543 ------------------
.../listing/dashboard_listing.test.tsx | 212 -------
.../listing/dashboard_listing.tsx | 383 ------------
.../public/dashboard_app/listing/index.ts | 9 -
.../dashboard_listing_page.test.tsx | 127 ++++
.../listing_page/dashboard_listing_page.tsx | 107 ++++
.../dashboard_no_match.tsx | 0
.../get_dashboard_list_item_link.test.ts | 0
.../get_dashboard_list_item_link.ts | 0
.../top_nav/use_dashboard_menu_items.tsx | 2 +-
.../_dashboard_listing_strings.ts | 144 +++++
.../confirm_overlays.tsx | 4 +-
.../dashboard_listing.test.tsx | 105 ++++
.../dashboard_listing/dashboard_listing.tsx | 264 +++++++++
.../dashboard_listing_empty_prompt.test.tsx | 111 ++++
.../dashboard_listing_empty_prompt.tsx | 157 +++++
.../dashboard_unsaved_listing.test.tsx | 23 +-
.../dashboard_unsaved_listing.tsx | 21 +-
.../public/dashboard_listing/index.tsx | 36 ++
src/plugins/dashboard/public/index.ts | 2 +
src/plugins/dashboard/public/plugin.tsx | 6 +-
.../dashboard_saved_object.stub.ts | 1 +
26 files changed, 1195 insertions(+), 1375 deletions(-)
delete mode 100644 src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap
delete mode 100644 src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx
delete mode 100644 src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx
delete mode 100644 src/plugins/dashboard/public/dashboard_app/listing/index.ts
create mode 100644 src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.test.tsx
create mode 100644 src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.tsx
rename src/plugins/dashboard/public/dashboard_app/{listing => listing_page}/dashboard_no_match.tsx (100%)
rename src/plugins/dashboard/public/dashboard_app/{listing => listing_page}/get_dashboard_list_item_link.test.ts (100%)
rename src/plugins/dashboard/public/dashboard_app/{listing => listing_page}/get_dashboard_list_item_link.ts (100%)
create mode 100644 src/plugins/dashboard/public/dashboard_listing/_dashboard_listing_strings.ts
rename src/plugins/dashboard/public/{dashboard_app/listing => dashboard_listing}/confirm_overlays.tsx (96%)
create mode 100644 src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx
create mode 100644 src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx
create mode 100644 src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx
create mode 100644 src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx
rename src/plugins/dashboard/public/{dashboard_app/listing => dashboard_listing}/dashboard_unsaved_listing.test.tsx (88%)
rename src/plugins/dashboard/public/{dashboard_app/listing => dashboard_listing}/dashboard_unsaved_listing.tsx (92%)
create mode 100644 src/plugins/dashboard/public/dashboard_listing/index.tsx
diff --git a/examples/portable_dashboards_example/public/app.tsx b/examples/portable_dashboards_example/public/app.tsx
index 052c74bd9b61..a99ecc627479 100644
--- a/examples/portable_dashboards_example/public/app.tsx
+++ b/examples/portable_dashboards_example/public/app.tsx
@@ -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(
+ ,
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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) {
+ {'Install web logs sample data to run the embeddable dashboard examples.'}
;
+ }
+ const { dataViews, logsSampleDashboardId } = dataviewResults;
+ return (
<>
@@ -42,16 +99,37 @@ export const renderApp = async (
>
- ) : (
- {'Install web logs sample data to run the embeddable dashboard examples.'}
);
+ }, [dataviewResults, loading]);
- ReactDOM.render(
+ return (
-
- {examples}
- ,
- element
+
+
+ history.push(DASHBOARD_LIST_PATH)}>
+ View portable dashboard listing page
+
+
+ {usageDemos}
+
+
+ );
+};
+
+const PortableDashboardListingDemo = ({ history }: { history: AppMountParameters['history'] }) => {
+ return (
+
+ alert(`Here's where I would redirect you to ${dashboardId ?? 'a new Dashboard'}`)
+ }
+ getDashboardUrl={() => 'https://www.elastic.co/'}
+ >
+ history.push(DASHBOARD_DEMO_PATH)}>
+ Go back to usage demos
+
+
+
+
+
);
- return () => ReactDOM.unmountComponentAtNode(element);
};
diff --git a/examples/portable_dashboards_example/tsconfig.json b/examples/portable_dashboards_example/tsconfig.json
index 3b96a17c085b..338fd93863aa 100644
--- a/examples/portable_dashboards_example/tsconfig.json
+++ b/examples/portable_dashboards_example/tsconfig.json
@@ -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"
]
}
diff --git a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts
index d34bc51343cd..e87a74d428f9 100644
--- a/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts
+++ b/src/plugins/dashboard/public/dashboard_app/_dashboard_app_strings.ts
@@ -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
*/
diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
index 31c6caf84020..6956ea7024a3 100644
--- a/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/dashboard_router.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap
deleted file mode 100644
index 074697e980d1..000000000000
--- a/src/plugins/dashboard/public/dashboard_app/listing/__snapshots__/dashboard_listing.test.tsx.snap
+++ /dev/null
@@ -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`] = `
-
-
- Create a dashboard
-
- }
- body={
-
-
- Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
-
-
-
- Add some sample data
- ,
- }
- }
- />
-
-
- }
- iconType="dashboardApp"
- title={
-
- Create your first dashboard
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter="search by title"
- tableListTitle="Dashboards"
- >
-
-
-
-`;
-
-exports[`after fetch initialFilter 1`] = `
-
-
- Create a dashboard
-
- }
- body={
-
-
- Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
-
-
-
- Add some sample data
- ,
- }
- }
- />
-
-
- }
- iconType="dashboardApp"
- title={
-
- Create your first dashboard
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter=""
- tableListTitle="Dashboards"
- >
-
-
-
-`;
-
-exports[`after fetch renders all table rows 1`] = `
-
-
- Create a dashboard
-
- }
- body={
-
-
- Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
-
-
-
- Add some sample data
- ,
- }
- }
- />
-
-
- }
- iconType="dashboardApp"
- title={
-
- Create your first dashboard
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter=""
- tableListTitle="Dashboards"
- >
-
-
-
-`;
-
-exports[`after fetch renders call to action when no dashboards exist 1`] = `
-
-
- Create a dashboard
-
- }
- body={
-
-
- Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
-
-
-
- Add some sample data
- ,
- }
- }
- />
-
-
- }
- iconType="dashboardApp"
- title={
-
- Create your first dashboard
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter=""
- tableListTitle="Dashboards"
- >
-
-
-
-`;
-
-exports[`after fetch renders call to action with continue when no dashboards exist but one is in progress 1`] = `
-
-
-
-
- Discard changes
-
-
-
-
- Continue editing
-
-
-
- }
- body={
-
-
- Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
-
-
- }
- iconType="dashboardApp"
- title={
-
- Dashboard in progress
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter=""
- tableListTitle="Dashboards"
- >
-
-
-
-`;
-
-exports[`after fetch showWriteControls 1`] = `
-
-
- There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.
-
- }
- iconType="glasses"
- title={
-
- No dashboards to view
-
- }
- />
- }
- entityName="dashboard"
- entityNamePlural="dashboards"
- findItems={[Function]}
- getDetailViewLink={[Function]}
- headingId="dashboardListingHeading"
- id="dashboard"
- initialFilter=""
- tableListTitle="Dashboards"
- >
-
-
-
-`;
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx
deleted file mode 100644
index e638fbca60f0..000000000000
--- a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.test.tsx
+++ /dev/null
@@ -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 (
-
- ({
- searchTerm: '',
- tagReferences: [],
- tagReferencesToExclude: [],
- }),
- components: {
- TagList: () => null,
- },
- },
- } as unknown as TableListViewKibanaDependencies['savedObjectsTagging']
- }
- FormattedRelative={FormattedRelative}
- toMountPoint={() => () => () => undefined}
- >
- {children}
-
-
- );
- };
- const component = mount(, { 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();
- });
-});
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx
deleted file mode 100644
index d857e640c04d..000000000000
--- a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_listing.tsx
+++ /dev/null
@@ -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
-): 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(false);
- useMount(() => {
- (async () => setShowNoDataPage(await isDashboardAppInNoDataState()))();
- });
-
- const [unsavedDashboardIds, setUnsavedDashboardIds] = useState(
- 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 (
-
- {noItemsStrings.getReadonlyTitle()}
-
- }
- body={{noItemsStrings.getReadonlyBody()}
}
- />
- );
- }
-
- const isEditingFirstDashboard = unsavedDashboardIds.length === 1;
-
- const emptyAction = isEditingFirstDashboard ? (
-
-
-
- confirmDiscardUnsavedChanges(() => {
- dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
- setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
- })
- }
- data-test-subj="discardDashboardPromptButton"
- aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
- >
- {dashboardUnsavedListingStrings.getDiscardTitle()}
-
-
-
- redirectTo({ destination: 'dashboard' })}
- data-test-subj="newItemButton"
- aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
- >
- {dashboardUnsavedListingStrings.getEditTitle()}
-
-
-
- ) : (
-
- {noItemsStrings.getCreateNewDashboardText()}
-
- );
-
- return (
-
- {isEditingFirstDashboard
- ? noItemsStrings.getReadEditInProgressTitle()
- : noItemsStrings.getReadEditTitle()}
-
- }
- body={
- <>
- {noItemsStrings.getReadEditDashboardDescription()}
- {!isEditingFirstDashboard && (
-
-
- application.navigateToApp('home', {
- path: '#/tutorial_directory/sampleData',
- })
- }
- >
- {noItemsStrings.getSampleDataLinkText()}
-
- ),
- }}
- />
-
- )}
- >
- }
- 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 && (
- setShowNoDataPage(false)} />
- )}
- {!showNoDataPage && (
-
- 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)
- }
- >
-
- setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
- }
- />
-
- )}
- >
- );
-};
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/index.ts b/src/plugins/dashboard/public/dashboard_app/listing/index.ts
deleted file mode 100644
index 5b3caaf4d391..000000000000
--- a/src/plugins/dashboard/public/dashboard_app/listing/index.ts
+++ /dev/null
@@ -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';
diff --git a/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.test.tsx b/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.test.tsx
new file mode 100644
index 000000000000..3ae147d9e7e4
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.test.tsx
@@ -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 {children};
+ };
+ const component = mount(, { 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,
+ });
+});
diff --git a/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.tsx b/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.tsx
new file mode 100644
index 000000000000..9e46bb1c3cab
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_listing_page.tsx
@@ -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(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 && (
+ setShowNoDataPage(false)} />
+ )}
+ {!showNoDataPage && (
+ {
+ redirectTo({ destination: 'dashboard', id, editMode: viewMode === ViewMode.EDIT });
+ }}
+ getDashboardUrl={(id, timeRestore) => {
+ return getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore);
+ }}
+ />
+ )}
+ >
+ );
+};
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_no_match.tsx b/src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_no_match.tsx
similarity index 100%
rename from src/plugins/dashboard/public/dashboard_app/listing/dashboard_no_match.tsx
rename to src/plugins/dashboard/public/dashboard_app/listing_page/dashboard_no_match.tsx
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/dashboard_app/listing_page/get_dashboard_list_item_link.test.ts
similarity index 100%
rename from src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.test.ts
rename to src/plugins/dashboard/public/dashboard_app/listing_page/get_dashboard_list_item_link.test.ts
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/dashboard_app/listing_page/get_dashboard_list_item_link.ts
similarity index 100%
rename from src/plugins/dashboard/public/dashboard_app/listing/get_dashboard_list_item_link.ts
rename to src/plugins/dashboard/public/dashboard_app/listing_page/get_dashboard_list_item_link.ts
diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx
index 645aa42ec918..b548d2e39afc 100644
--- a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx
+++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx
@@ -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,
diff --git a/src/plugins/dashboard/public/dashboard_listing/_dashboard_listing_strings.ts b/src/plugins/dashboard/public/dashboard_listing/_dashboard_listing_strings.ts
new file mode 100644
index 000000000000..df036a01fcf9
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/_dashboard_listing_strings.ts
@@ -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',
+ }),
+};
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/dashboard_listing/confirm_overlays.tsx
similarity index 96%
rename from src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx
rename to src/plugins/dashboard/public/dashboard_listing/confirm_overlays.tsx
index b8d6f436c38b..03027cda242b 100644
--- a/src/plugins/dashboard/public/dashboard_app/listing/confirm_overlays.tsx
+++ b/src/plugins/dashboard/public/dashboard_listing/confirm_overlays.tsx
@@ -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';
diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx
new file mode 100644
index 000000000000..383b51fbd65f
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.test.tsx
@@ -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 }) {
+ const props = { ...makeDefaultProps(), ...incomingProps };
+ const wrappingComponent: React.FC<{
+ children: React.ReactNode;
+ }> = ({ children }) => {
+ return {children};
+ };
+ const component = mount(, { 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
+ );
+});
diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx
new file mode 100644
index 000000000000..8572356687fd
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx
@@ -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
+): 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(
+ 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 = (
+
+ );
+
+ const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
+
+ return (
+
+
+
+ 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}
+
+ setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
+ }
+ />
+ >
+
+
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default DashboardListing;
diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx
new file mode 100644
index 000000000000..886d43a1db6d
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.test.tsx
@@ -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;
+}) {
+ const props = { ...makeDefaultProps(), ...incomingProps };
+ const wrappingComponent: React.FC<{
+ children: React.ReactNode;
+ }> = ({ children }) => {
+ return {children};
+ };
+ const component = mount(, { 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();
+});
diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx
new file mode 100644
index 000000000000..a518c520bcbd
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_empty_prompt.tsx
@@ -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>;
+ 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 (
+
+ {noItemsStrings.getCreateNewDashboardText()}
+
+ );
+ }
+ return (
+
+
+
+ confirmDiscardUnsavedChanges(() => {
+ dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
+ setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
+ })
+ }
+ data-test-subj="discardDashboardPromptButton"
+ aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
+ >
+ {dashboardUnsavedListingStrings.getDiscardTitle()}
+
+
+
+ goToDashboard()}
+ aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
+ >
+ {dashboardUnsavedListingStrings.getEditTitle()}
+
+
+
+ );
+ }, [
+ dashboardSessionStorage,
+ isEditingFirstDashboard,
+ setUnsavedDashboardIds,
+ goToDashboard,
+ createItem,
+ ]);
+
+ if (!showWriteControls) {
+ return (
+
+ {noItemsStrings.getReadonlyTitle()}
+
+ }
+ body={{noItemsStrings.getReadonlyBody()}
}
+ />
+ );
+ }
+
+ return (
+
+ {isEditingFirstDashboard
+ ? noItemsStrings.getReadEditInProgressTitle()
+ : noItemsStrings.getReadEditTitle()}
+
+ }
+ body={
+ <>
+ {noItemsStrings.getReadEditDashboardDescription()}
+ {!isEditingFirstDashboard && (
+
+
+ application.navigateToApp('home', {
+ path: '#/tutorial_directory/sampleData',
+ })
+ }
+ >
+ {noItemsStrings.getSampleDataLinkText()}
+
+ ),
+ }}
+ />
+
+ )}
+ >
+ }
+ actions={getEmptyAction()}
+ />
+ );
+};
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.test.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.test.tsx
similarity index 88%
rename from src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.test.tsx
rename to src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.test.tsx
index 34a78c5181b9..6ae8050b0df5 100644
--- a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.test.tsx
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.test.tsx
@@ -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 }) {
+ 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 () => {
diff --git a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx
similarity index 92%
rename from src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx
rename to src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx
index 2cf39d8ccb0d..f4b7f91db77d 100644
--- a/src/plugins/dashboard/public/dashboard_app/listing/dashboard_unsaved_listing.tsx
+++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_unsaved_listing.tsx
@@ -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(
diff --git a/src/plugins/dashboard/public/dashboard_listing/index.tsx b/src/plugins/dashboard/public/dashboard_listing/index.tsx
new file mode 100644
index 000000000000..92febf2904bd
--- /dev/null
+++ b/src/plugins/dashboard/public/dashboard_listing/index.tsx
@@ -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 } />;
+};
+
+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 (
+ }>
+
+
+ );
+};
diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts
index 4179de9e4f89..af7c9a307957 100644
--- a/src/plugins/dashboard/public/index.ts
+++ b/src/plugins/dashboard/public/index.ts
@@ -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,
diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx
index 3c4031072ca9..093e879280d6 100644
--- a/src/plugins/dashboard/public/plugin.tsx
+++ b/src/plugins/dashboard/public/plugin.tsx
@@ -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;
}
+export let resolveServicesReady: () => void;
+export const servicesReady = new Promise((resolve) => (resolveServicesReady = resolve));
+
export class DashboardPlugin
implements
Plugin
@@ -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(
diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts
index 5c3148743a32..4e9fbdb31dca 100644
--- a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts
+++ b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object.stub.ts
@@ -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({