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({