mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Portable Dashboards] Export Dashboard Listing Table (#154295)
export the Dashboard Listing Page component lazily. This component is a wrapper around the TableListView and can be used as-is.
This commit is contained in:
parent
8d9777b94f
commit
b689c27ce2
26 changed files with 1195 additions and 1375 deletions
|
@ -6,11 +6,15 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use/lib';
|
||||
import { Router, Redirect, Switch } from 'react-router-dom';
|
||||
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { Route } from '@kbn/shared-ux-router';
|
||||
import { AppMountParameters } from '@kbn/core/public';
|
||||
import { EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui';
|
||||
import { DashboardListingTable } from '@kbn/dashboard-plugin/public';
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
|
||||
import { DualReduxExample } from './dual_redux_example';
|
||||
|
@ -20,17 +24,70 @@ import { StaticByReferenceExample } from './static_by_reference_example';
|
|||
import { DynamicByReferenceExample } from './dynamically_add_panels_example';
|
||||
import { DashboardWithControlsExample } from './dashboard_with_controls_example';
|
||||
|
||||
const DASHBOARD_DEMO_PATH = '/dashboardDemo';
|
||||
const DASHBOARD_LIST_PATH = '/listingDemo';
|
||||
|
||||
export const renderApp = async (
|
||||
{ data, dashboard }: PortableDashboardsExampleStartDeps,
|
||||
{ element }: AppMountParameters
|
||||
{ element, history }: AppMountParameters
|
||||
) => {
|
||||
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
|
||||
const findDashboardsService = await dashboard.findDashboardsService();
|
||||
const logsSampleDashboardId = (await findDashboardsService?.findByTitle('[Logs] Web Traffic'))
|
||||
?.id;
|
||||
ReactDOM.render(
|
||||
<PortableDashboardsDemos data={data} history={history} dashboard={dashboard} />,
|
||||
element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
||||
|
||||
const examples =
|
||||
dataViews.length > 0 ? (
|
||||
const PortableDashboardsDemos = ({
|
||||
data,
|
||||
dashboard,
|
||||
history,
|
||||
}: {
|
||||
data: PortableDashboardsExampleStartDeps['data'];
|
||||
dashboard: PortableDashboardsExampleStartDeps['dashboard'];
|
||||
history: AppMountParameters['history'];
|
||||
}) => {
|
||||
return (
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={DASHBOARD_DEMO_PATH} />
|
||||
</Route>
|
||||
<Route path={DASHBOARD_LIST_PATH}>
|
||||
<PortableDashboardListingDemo history={history} />
|
||||
</Route>
|
||||
<Route path={DASHBOARD_DEMO_PATH}>
|
||||
<DashboardsDemo data={data} dashboard={dashboard} history={history} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardsDemo = ({
|
||||
data,
|
||||
history,
|
||||
dashboard,
|
||||
}: {
|
||||
history: AppMountParameters['history'];
|
||||
data: PortableDashboardsExampleStartDeps['data'];
|
||||
dashboard: PortableDashboardsExampleStartDeps['dashboard'];
|
||||
}) => {
|
||||
const { loading, value: dataviewResults } = useAsync(async () => {
|
||||
const dataViews = await data.dataViews.find('kibana_sample_data_logs');
|
||||
const findDashboardsService = await dashboard.findDashboardsService();
|
||||
const logsSampleDashboardId = (await findDashboardsService?.findByTitle('[Logs] Web Traffic'))
|
||||
?.id;
|
||||
return { dataViews, logsSampleDashboardId };
|
||||
}, []);
|
||||
|
||||
const usageDemos = useMemo(() => {
|
||||
if (loading || !dataviewResults) return null;
|
||||
if (dataviewResults?.dataViews.length === 0) {
|
||||
<div>{'Install web logs sample data to run the embeddable dashboard examples.'}</div>;
|
||||
}
|
||||
const { dataViews, logsSampleDashboardId } = dataviewResults;
|
||||
return (
|
||||
<>
|
||||
<DashboardWithControlsExample dataView={dataViews[0]} />
|
||||
<EuiSpacer size="xl" />
|
||||
|
@ -42,16 +99,37 @@ export const renderApp = async (
|
|||
<EuiSpacer size="xl" />
|
||||
<StaticByValueExample />
|
||||
</>
|
||||
) : (
|
||||
<div>{'Install web logs sample data to run the embeddable dashboard examples.'}</div>
|
||||
);
|
||||
}, [dataviewResults, loading]);
|
||||
|
||||
ReactDOM.render(
|
||||
return (
|
||||
<KibanaPageTemplate>
|
||||
<KibanaPageTemplate.Header pageTitle="Portable Dashboards" />
|
||||
<KibanaPageTemplate.Section>{examples}</KibanaPageTemplate.Section>
|
||||
</KibanaPageTemplate>,
|
||||
element
|
||||
<KibanaPageTemplate.Header pageTitle="Portable dashboards usage" />
|
||||
<KibanaPageTemplate.Section>
|
||||
<EuiButton onClick={() => history.push(DASHBOARD_LIST_PATH)}>
|
||||
View portable dashboard listing page
|
||||
</EuiButton>
|
||||
<EuiSpacer size="xl" />
|
||||
{usageDemos}
|
||||
</KibanaPageTemplate.Section>
|
||||
</KibanaPageTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
const PortableDashboardListingDemo = ({ history }: { history: AppMountParameters['history'] }) => {
|
||||
return (
|
||||
<DashboardListingTable
|
||||
goToDashboard={(dashboardId) =>
|
||||
alert(`Here's where I would redirect you to ${dashboardId ?? 'a new Dashboard'}`)
|
||||
}
|
||||
getDashboardUrl={() => 'https://www.elastic.co/'}
|
||||
>
|
||||
<EuiButton onClick={() => history.push(DASHBOARD_DEMO_PATH)}>
|
||||
Go back to usage demos
|
||||
</EuiButton>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiCallOut title="You can render something cool here" iconType="search" />
|
||||
<EuiSpacer size="xl" />
|
||||
</DashboardListingTable>
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -15,13 +15,8 @@ import { render, unmountComponentAtNode } from 'react-dom';
|
|||
import { Switch, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom';
|
||||
import { Route } from '@kbn/shared-ux-router';
|
||||
|
||||
import {
|
||||
TableListViewKibanaDependencies,
|
||||
TableListViewKibanaProvider,
|
||||
} from '@kbn/content-management-table-list';
|
||||
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { AppMountParameters, CoreSetup } from '@kbn/core/public';
|
||||
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
|
@ -32,14 +27,13 @@ import {
|
|||
LANDING_PAGE_PATH,
|
||||
VIEW_DASHBOARD_URL,
|
||||
} from '../dashboard_constants';
|
||||
import { DashboardListing } from './listing';
|
||||
import { DashboardApp } from './dashboard_app';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { DashboardNoMatch } from './listing/dashboard_no_match';
|
||||
import { DashboardNoMatch } from './listing_page/dashboard_no_match';
|
||||
import { createDashboardEditUrl } from '../dashboard_constants';
|
||||
import { DashboardStart, DashboardStartDependencies } from '../plugin';
|
||||
import { DashboardMountContext } from './hooks/dashboard_mount_context';
|
||||
import { DashboardApplicationService } from '../services/application/types';
|
||||
import { DashboardListingPage } from './listing_page/dashboard_listing_page';
|
||||
import { dashboardReadonlyBadge, getDashboardPageTitle } from './_dashboard_app_strings';
|
||||
import { DashboardEmbedSettings, DashboardMountContextProps, RedirectToProps } from './types';
|
||||
|
||||
|
@ -57,25 +51,15 @@ export interface DashboardMountProps {
|
|||
mountContext: DashboardMountContextProps;
|
||||
}
|
||||
|
||||
// because the type of `application.capabilities.advancedSettings` is so generic, the provider
|
||||
// requiring the `save` key to be part of it is causing type issues - so, creating a custom type
|
||||
type TableListViewApplicationService = DashboardApplicationService & {
|
||||
capabilities: { advancedSettings: { save: boolean } };
|
||||
};
|
||||
|
||||
export async function mountApp({ core, element, appUnMounted, mountContext }: DashboardMountProps) {
|
||||
const {
|
||||
application,
|
||||
chrome: { setBadge, docTitle, setHelpExtension },
|
||||
dashboardCapabilities: { showWriteControls },
|
||||
documentationLinks: { dashboardDocLink },
|
||||
settings: { uiSettings },
|
||||
savedObjectsTagging,
|
||||
data: dataStart,
|
||||
notifications,
|
||||
embeddable,
|
||||
overlays,
|
||||
http,
|
||||
} = pluginServices.getServices();
|
||||
|
||||
let globalEmbedSettings: DashboardEmbedSettings | undefined;
|
||||
|
@ -140,7 +124,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
|
|||
routerHistory = routeProps.history;
|
||||
}
|
||||
return (
|
||||
<DashboardListing
|
||||
<DashboardListingPage
|
||||
initialFilter={filter}
|
||||
title={title}
|
||||
kbnUrlStateStorage={getUrlStateStorage(routeProps.history)}
|
||||
|
@ -170,37 +154,19 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
|
|||
<I18nProvider>
|
||||
<DashboardMountContext.Provider value={mountContext}>
|
||||
<KibanaThemeProvider theme$={core.theme.theme$}>
|
||||
<TableListViewKibanaProvider
|
||||
{...{
|
||||
core: {
|
||||
application: application as TableListViewApplicationService,
|
||||
notifications,
|
||||
overlays,
|
||||
http,
|
||||
},
|
||||
toMountPoint,
|
||||
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
|
||||
? ({
|
||||
ui: savedObjectsTagging,
|
||||
} as TableListViewKibanaDependencies['savedObjectsTagging'])
|
||||
: undefined,
|
||||
FormattedRelative,
|
||||
}}
|
||||
>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[CREATE_NEW_DASHBOARD_URL, `${VIEW_DASHBOARD_URL}/:id`]}
|
||||
render={renderDashboard}
|
||||
/>
|
||||
<Route exact path={LANDING_PAGE_PATH} render={renderListingPage} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={LANDING_PAGE_PATH} />
|
||||
</Route>
|
||||
<Route render={renderNoMatch} />
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</TableListViewKibanaProvider>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<Route
|
||||
path={[CREATE_NEW_DASHBOARD_URL, `${VIEW_DASHBOARD_URL}/:id`]}
|
||||
render={renderDashboard}
|
||||
/>
|
||||
<Route exact path={LANDING_PAGE_PATH} render={renderListingPage} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={LANDING_PAGE_PATH} />
|
||||
</Route>
|
||||
<Route render={renderNoMatch} />
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</KibanaThemeProvider>
|
||||
</DashboardMountContext.Provider>
|
||||
</I18nProvider>
|
||||
|
|
|
@ -1,543 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`after fetch When given a title that matches multiple dashboards, filter on the title 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
title="search by title"
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
createItem={[Function]}
|
||||
deleteItems={[Function]}
|
||||
editItem={[Function]}
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
fill={true}
|
||||
iconType="plusInCircle"
|
||||
onClick={[Function]}
|
||||
size="m"
|
||||
>
|
||||
Create a dashboard
|
||||
</EuiButton>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
values={
|
||||
Object {
|
||||
"sampleDataInstallLink": <EuiLink
|
||||
onClick={[Function]}
|
||||
>
|
||||
Add some sample data
|
||||
</EuiLink>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
Create your first dashboard
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter="search by title"
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"dashboardUnsavedOne",
|
||||
"dashboardUnsavedTwo",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
||||
|
||||
exports[`after fetch initialFilter 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
createItem={[Function]}
|
||||
deleteItems={[Function]}
|
||||
editItem={[Function]}
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
fill={true}
|
||||
iconType="plusInCircle"
|
||||
onClick={[Function]}
|
||||
size="m"
|
||||
>
|
||||
Create a dashboard
|
||||
</EuiButton>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
values={
|
||||
Object {
|
||||
"sampleDataInstallLink": <EuiLink
|
||||
onClick={[Function]}
|
||||
>
|
||||
Add some sample data
|
||||
</EuiLink>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
Create your first dashboard
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter=""
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"dashboardUnsavedOne",
|
||||
"dashboardUnsavedTwo",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
||||
|
||||
exports[`after fetch renders all table rows 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
createItem={[Function]}
|
||||
deleteItems={[Function]}
|
||||
editItem={[Function]}
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
fill={true}
|
||||
iconType="plusInCircle"
|
||||
onClick={[Function]}
|
||||
size="m"
|
||||
>
|
||||
Create a dashboard
|
||||
</EuiButton>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
values={
|
||||
Object {
|
||||
"sampleDataInstallLink": <EuiLink
|
||||
onClick={[Function]}
|
||||
>
|
||||
Add some sample data
|
||||
</EuiLink>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
Create your first dashboard
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter=""
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"dashboardUnsavedOne",
|
||||
"dashboardUnsavedTwo",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
||||
|
||||
exports[`after fetch renders call to action when no dashboards exist 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
createItem={[Function]}
|
||||
deleteItems={[Function]}
|
||||
editItem={[Function]}
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiButton
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
fill={true}
|
||||
iconType="plusInCircle"
|
||||
onClick={[Function]}
|
||||
size="m"
|
||||
>
|
||||
Create a dashboard
|
||||
</EuiButton>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
|
||||
</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
values={
|
||||
Object {
|
||||
"sampleDataInstallLink": <EuiLink
|
||||
onClick={[Function]}
|
||||
>
|
||||
Add some sample data
|
||||
</EuiLink>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
Create your first dashboard
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter=""
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"dashboardUnsavedOne",
|
||||
"dashboardUnsavedTwo",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
||||
|
||||
exports[`after fetch renders call to action with continue when no dashboards exist but one is in progress 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
createItem={[Function]}
|
||||
deleteItems={[Function]}
|
||||
editItem={[Function]}
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
actions={
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
justifyContent="center"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
aria-label="Discard changes to New Dashboard"
|
||||
color="danger"
|
||||
data-test-subj="discardDashboardPromptButton"
|
||||
onClick={[Function]}
|
||||
size="s"
|
||||
>
|
||||
Discard changes
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiButton
|
||||
aria-label="Continue editing New Dashboard"
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
fill={true}
|
||||
iconType="pencil"
|
||||
onClick={[Function]}
|
||||
size="m"
|
||||
>
|
||||
Continue editing
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
body={
|
||||
<React.Fragment>
|
||||
<p>
|
||||
Analyze all of your Elastic data in one place by creating a dashboard and adding visualizations.
|
||||
</p>
|
||||
</React.Fragment>
|
||||
}
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
Dashboard in progress
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter=""
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"unsavedDashboard",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
||||
|
||||
exports[`after fetch showWriteControls 1`] = `
|
||||
<DashboardListing
|
||||
kbnUrlStateStorage={
|
||||
Object {
|
||||
"cancel": [Function],
|
||||
"change$": [Function],
|
||||
"get": [Function],
|
||||
"kbnUrlControls": Object {
|
||||
"cancel": [Function],
|
||||
"flush": [Function],
|
||||
"getPendingUrl": [Function],
|
||||
"listen": [Function],
|
||||
"update": [Function],
|
||||
"updateAsync": [Function],
|
||||
},
|
||||
"set": [Function],
|
||||
}
|
||||
}
|
||||
redirectTo={[MockFunction]}
|
||||
>
|
||||
<Memo(TableListViewComp)
|
||||
emptyPrompt={
|
||||
<EuiEmptyPrompt
|
||||
body={
|
||||
<p>
|
||||
There are no available dashboards. To change your permissions to view the dashboards in this space, contact your administrator.
|
||||
</p>
|
||||
}
|
||||
iconType="glasses"
|
||||
title={
|
||||
<h1
|
||||
data-test-subj="emptyListPrompt"
|
||||
id="dashboardListingHeading"
|
||||
>
|
||||
No dashboards to view
|
||||
</h1>
|
||||
}
|
||||
/>
|
||||
}
|
||||
entityName="dashboard"
|
||||
entityNamePlural="dashboards"
|
||||
findItems={[Function]}
|
||||
getDetailViewLink={[Function]}
|
||||
headingId="dashboardListingHeading"
|
||||
id="dashboard"
|
||||
initialFilter=""
|
||||
tableListTitle="Dashboards"
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={[MockFunction]}
|
||||
refreshUnsavedDashboards={[Function]}
|
||||
unsavedDashboardIds={
|
||||
Array [
|
||||
"dashboardUnsavedOne",
|
||||
"dashboardUnsavedTwo",
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Memo(TableListViewComp)>
|
||||
</DashboardListing>
|
||||
`;
|
|
@ -1,212 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
TableListViewKibanaDependencies,
|
||||
TableListViewKibanaProvider,
|
||||
} from '@kbn/content-management-table-list';
|
||||
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
|
||||
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
|
||||
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
return {
|
||||
useLocation: () => ({
|
||||
search: '',
|
||||
}),
|
||||
useHistory: () => ({
|
||||
push: () => undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function makeDefaultProps(): DashboardListingProps {
|
||||
return {
|
||||
redirectTo: jest.fn(),
|
||||
kbnUrlStateStorage: createKbnUrlStateStorage(),
|
||||
};
|
||||
}
|
||||
|
||||
function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) {
|
||||
const props = incomingProps ?? makeDefaultProps();
|
||||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { application, notifications, savedObjectsTagging, http, overlays } =
|
||||
pluginServices.getServices();
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<TableListViewKibanaProvider
|
||||
core={{
|
||||
application:
|
||||
application as unknown as TableListViewKibanaDependencies['core']['application'],
|
||||
notifications,
|
||||
http,
|
||||
overlays,
|
||||
}}
|
||||
savedObjectsTagging={
|
||||
{
|
||||
ui: {
|
||||
...savedObjectsTagging,
|
||||
parseSearchQuery: async () => ({
|
||||
searchTerm: '',
|
||||
tagReferences: [],
|
||||
tagReferencesToExclude: [],
|
||||
}),
|
||||
components: {
|
||||
TagList: () => null,
|
||||
},
|
||||
},
|
||||
} as unknown as TableListViewKibanaDependencies['savedObjectsTagging']
|
||||
}
|
||||
FormattedRelative={FormattedRelative}
|
||||
toMountPoint={() => () => () => undefined}
|
||||
>
|
||||
{children}
|
||||
</TableListViewKibanaProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
const component = mount(<DashboardListing {...props} />, { wrappingComponent });
|
||||
return { component, props };
|
||||
}
|
||||
|
||||
describe('after fetch', () => {
|
||||
test('renders all table rows', async () => {
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders call to action when no dashboards exist', async () => {
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findSavedObjects as jest.Mock
|
||||
).mockResolvedValue({
|
||||
total: 0,
|
||||
hits: [],
|
||||
});
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders call to action with continue when no dashboards exist but one is in progress', async () => {
|
||||
pluginServices.getServices().dashboardSessionStorage.getDashboardIdsWithUnsavedChanges = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce([DASHBOARD_PANELS_UNSAVED_ID])
|
||||
.mockReturnValue(['dashboardUnsavedOne', 'dashboardUnsavedTwo']);
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findSavedObjects as jest.Mock
|
||||
).mockResolvedValue({
|
||||
total: 0,
|
||||
hits: [],
|
||||
});
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('initialFilter', async () => {
|
||||
const props = makeDefaultProps();
|
||||
props.initialFilter = 'testFilter';
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('When given a title that matches multiple dashboards, filter on the title', async () => {
|
||||
const title = 'search by title';
|
||||
const props = makeDefaultProps();
|
||||
props.title = title;
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props }));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
expect(props.redirectTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('When given a title that matches one dashboard, redirect to dashboard', async () => {
|
||||
const title = 'search by title';
|
||||
const props = makeDefaultProps();
|
||||
props.title = title;
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
|
||||
).mockResolvedValue({ id: 'you_found_me' });
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props }));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||
destination: 'dashboard',
|
||||
id: 'you_found_me',
|
||||
useReplace: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('showWriteControls', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
// Ensure the state changes are reflected
|
||||
component!.update();
|
||||
expect(component!).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -1,383 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import useMount from 'react-use/lib/useMount';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiLink,
|
||||
EuiButton,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
|
||||
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
|
||||
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public';
|
||||
import { TableListView, type UserContentCommonSchema } from '@kbn/content-management-table-list';
|
||||
|
||||
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
|
||||
import { SAVED_OBJECT_DELETE_TIME, SAVED_OBJECT_LOADED_TIME } from '../../dashboard_constants';
|
||||
|
||||
import {
|
||||
getDashboardBreadcrumb,
|
||||
dashboardListingTableStrings,
|
||||
noItemsStrings,
|
||||
dashboardUnsavedListingStrings,
|
||||
getNewDashboardTitle,
|
||||
dashboardListingErrorStrings,
|
||||
} from '../_dashboard_app_strings';
|
||||
import {
|
||||
DashboardAppNoDataPage,
|
||||
isDashboardAppInNoDataState,
|
||||
} from '../no_data/dashboard_app_no_data';
|
||||
import { DashboardRedirect } from '../types';
|
||||
import { DashboardAttributes } from '../../../common';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
|
||||
import { DASHBOARD_SAVED_OBJECT_TYPE } from '../../dashboard_constants';
|
||||
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
|
||||
import { confirmCreateWithUnsaved, confirmDiscardUnsavedChanges } from './confirm_overlays';
|
||||
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
|
||||
|
||||
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
|
||||
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
|
||||
|
||||
interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
|
||||
attributes: {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeRestore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const toTableListViewSavedObject = (
|
||||
savedObject: SimpleSavedObject<DashboardAttributes>
|
||||
): DashboardSavedObjectUserContent => {
|
||||
const { title, description, timeRestore } = savedObject.attributes;
|
||||
return {
|
||||
type: 'dashboard',
|
||||
id: savedObject.id,
|
||||
updatedAt: savedObject.updatedAt!,
|
||||
references: savedObject.references,
|
||||
attributes: {
|
||||
title,
|
||||
description,
|
||||
timeRestore,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface DashboardListingProps {
|
||||
kbnUrlStateStorage: IKbnUrlStateStorage;
|
||||
redirectTo: DashboardRedirect;
|
||||
initialFilter?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DashboardListing = ({
|
||||
title,
|
||||
redirectTo,
|
||||
initialFilter,
|
||||
kbnUrlStateStorage,
|
||||
}: DashboardListingProps) => {
|
||||
const {
|
||||
application,
|
||||
data: { query },
|
||||
dashboardSessionStorage,
|
||||
settings: { uiSettings },
|
||||
notifications: { toasts },
|
||||
chrome: { setBreadcrumbs },
|
||||
coreContext: { executionContext },
|
||||
dashboardCapabilities: { showWriteControls },
|
||||
dashboardSavedObject: { findDashboards, savedObjectsClient },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
|
||||
useMount(() => {
|
||||
(async () => setShowNoDataPage(await isDashboardAppInNoDataState()))();
|
||||
});
|
||||
|
||||
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
|
||||
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
|
||||
);
|
||||
|
||||
useExecutionContext(executionContext, {
|
||||
type: 'application',
|
||||
page: 'list',
|
||||
});
|
||||
|
||||
// Set breadcrumbs useEffect
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{
|
||||
text: getDashboardBreadcrumb(),
|
||||
},
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
// syncs `_g` portion of url with query services
|
||||
const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl(
|
||||
query,
|
||||
kbnUrlStateStorage
|
||||
);
|
||||
if (title) {
|
||||
findDashboards.findByTitle(title).then((result) => {
|
||||
if (!result) return;
|
||||
redirectTo({
|
||||
destination: 'dashboard',
|
||||
id: result.id,
|
||||
useReplace: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopSyncingQueryServiceStateWithUrl();
|
||||
};
|
||||
}, [title, redirectTo, query, kbnUrlStateStorage, findDashboards]);
|
||||
|
||||
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
|
||||
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
|
||||
const defaultFilter = title ? `${title}` : '';
|
||||
|
||||
const createItem = useCallback(() => {
|
||||
if (!dashboardSessionStorage.dashboardHasUnsavedEdits()) {
|
||||
redirectTo({ destination: 'dashboard' });
|
||||
} else {
|
||||
confirmCreateWithUnsaved(
|
||||
() => {
|
||||
dashboardSessionStorage.clearState();
|
||||
redirectTo({ destination: 'dashboard' });
|
||||
},
|
||||
() => redirectTo({ destination: 'dashboard' })
|
||||
);
|
||||
}
|
||||
}, [dashboardSessionStorage, redirectTo]);
|
||||
|
||||
const emptyPrompt = useMemo(() => {
|
||||
if (!showWriteControls) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="glasses"
|
||||
title={
|
||||
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
|
||||
{noItemsStrings.getReadonlyTitle()}
|
||||
</h1>
|
||||
}
|
||||
body={<p>{noItemsStrings.getReadonlyBody()}</p>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isEditingFirstDashboard = unsavedDashboardIds.length === 1;
|
||||
|
||||
const emptyAction = isEditingFirstDashboard ? (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
color="danger"
|
||||
onClick={() =>
|
||||
confirmDiscardUnsavedChanges(() => {
|
||||
dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
|
||||
})
|
||||
}
|
||||
data-test-subj="discardDashboardPromptButton"
|
||||
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
|
||||
>
|
||||
{dashboardUnsavedListingStrings.getDiscardTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
iconType="pencil"
|
||||
color="primary"
|
||||
onClick={() => redirectTo({ destination: 'dashboard' })}
|
||||
data-test-subj="newItemButton"
|
||||
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
|
||||
>
|
||||
{dashboardUnsavedListingStrings.getEditTitle()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<EuiButton onClick={createItem} fill iconType="plusInCircle" data-test-subj="newItemButton">
|
||||
{noItemsStrings.getCreateNewDashboardText()}
|
||||
</EuiButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
|
||||
{isEditingFirstDashboard
|
||||
? noItemsStrings.getReadEditInProgressTitle()
|
||||
: noItemsStrings.getReadEditTitle()}
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<p>{noItemsStrings.getReadEditDashboardDescription()}</p>
|
||||
{!isEditingFirstDashboard && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
values={{
|
||||
sampleDataInstallLink: (
|
||||
<EuiLink
|
||||
onClick={() =>
|
||||
application.navigateToApp('home', {
|
||||
path: '#/tutorial_directory/sampleData',
|
||||
})
|
||||
}
|
||||
>
|
||||
{noItemsStrings.getSampleDataLinkText()}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={emptyAction}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
redirectTo,
|
||||
createItem,
|
||||
application,
|
||||
showWriteControls,
|
||||
unsavedDashboardIds,
|
||||
dashboardSessionStorage,
|
||||
]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
(
|
||||
searchTerm: string,
|
||||
{
|
||||
references,
|
||||
referencesToExclude,
|
||||
}: {
|
||||
references?: SavedObjectsFindOptionsReference[];
|
||||
referencesToExclude?: SavedObjectsFindOptionsReference[];
|
||||
} = {}
|
||||
) => {
|
||||
const searchStartTime = window.performance.now();
|
||||
return findDashboards
|
||||
.findSavedObjects({
|
||||
search: searchTerm,
|
||||
size: listingLimit,
|
||||
hasReference: references,
|
||||
hasNoReference: referencesToExclude,
|
||||
})
|
||||
.then(({ total, hits }) => {
|
||||
const searchEndTime = window.performance.now();
|
||||
const searchDuration = searchEndTime - searchStartTime;
|
||||
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
|
||||
eventName: SAVED_OBJECT_LOADED_TIME,
|
||||
duration: searchDuration,
|
||||
meta: {
|
||||
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
},
|
||||
});
|
||||
return {
|
||||
total,
|
||||
hits: hits.map(toTableListViewSavedObject),
|
||||
};
|
||||
});
|
||||
},
|
||||
[findDashboards, listingLimit]
|
||||
);
|
||||
|
||||
const deleteItems = useCallback(
|
||||
async (dashboardsToDelete: Array<{ id: string }>) => {
|
||||
try {
|
||||
const deleteStartTime = window.performance.now();
|
||||
|
||||
await Promise.all(
|
||||
dashboardsToDelete.map(({ id }) => {
|
||||
dashboardSessionStorage.clearState(id);
|
||||
return savedObjectsClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id);
|
||||
})
|
||||
);
|
||||
|
||||
const deleteDuration = window.performance.now() - deleteStartTime;
|
||||
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
|
||||
eventName: SAVED_OBJECT_DELETE_TIME,
|
||||
duration: deleteDuration,
|
||||
meta: {
|
||||
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
total: dashboardsToDelete.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(),
|
||||
});
|
||||
}
|
||||
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
|
||||
},
|
||||
[savedObjectsClient, dashboardSessionStorage, toasts]
|
||||
);
|
||||
|
||||
const editItem = useCallback(
|
||||
({ id }: { id: string | undefined }) =>
|
||||
redirectTo({ destination: 'dashboard', id, editMode: true }),
|
||||
[redirectTo]
|
||||
);
|
||||
|
||||
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
|
||||
return (
|
||||
<>
|
||||
{showNoDataPage && (
|
||||
<DashboardAppNoDataPage onDataViewCreated={() => setShowNoDataPage(false)} />
|
||||
)}
|
||||
{!showNoDataPage && (
|
||||
<TableListView<DashboardSavedObjectUserContent>
|
||||
createItem={!showWriteControls ? undefined : createItem}
|
||||
deleteItems={!showWriteControls ? undefined : deleteItems}
|
||||
initialPageSize={initialPageSize}
|
||||
editItem={!showWriteControls ? undefined : editItem}
|
||||
initialFilter={initialFilter ?? defaultFilter}
|
||||
headingId="dashboardListingHeading"
|
||||
findItems={fetchItems}
|
||||
entityNamePlural={getEntityNamePlural()}
|
||||
tableListTitle={getTableListTitle()}
|
||||
entityName={getEntityName()}
|
||||
{...{
|
||||
emptyPrompt,
|
||||
listingLimit,
|
||||
}}
|
||||
id="dashboard"
|
||||
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
|
||||
getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore)
|
||||
}
|
||||
>
|
||||
<DashboardUnsavedListing
|
||||
redirectTo={redirectTo}
|
||||
unsavedDashboardIds={unsavedDashboardIds}
|
||||
refreshUnsavedDashboards={() =>
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
|
||||
}
|
||||
/>
|
||||
</TableListView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { DashboardListingPage, DashboardListingPageProps } from './dashboard_listing_page';
|
||||
|
||||
// Mock child components. The Dashboard listing page mostly passes down props to shared UX components which are tested in their own packages.
|
||||
import { DashboardListing } from '../../dashboard_listing/dashboard_listing';
|
||||
jest.mock('../../dashboard_listing/dashboard_listing', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
DashboardListing: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
import { DashboardAppNoDataPage } from '../no_data/dashboard_app_no_data';
|
||||
jest.mock('../no_data/dashboard_app_no_data', () => {
|
||||
const originalModule = jest.requireActual('../no_data/dashboard_app_no_data');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
DashboardAppNoDataPage: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
function makeDefaultProps(): DashboardListingPageProps {
|
||||
return {
|
||||
redirectTo: jest.fn(),
|
||||
kbnUrlStateStorage: createKbnUrlStateStorage(),
|
||||
};
|
||||
}
|
||||
|
||||
function mountWith({ props: incomingProps }: { props?: DashboardListingPageProps }) {
|
||||
const props = incomingProps ?? makeDefaultProps();
|
||||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return <I18nProvider>{children}</I18nProvider>;
|
||||
};
|
||||
const component = mount(<DashboardListingPage {...props} />, { wrappingComponent });
|
||||
return { component, props };
|
||||
}
|
||||
|
||||
test('renders analytics no data page when the user has no data view', async () => {
|
||||
pluginServices.getServices().data.dataViews.hasData.hasUserDataView = jest
|
||||
.fn()
|
||||
.mockResolvedValue(false);
|
||||
|
||||
let component: ReactWrapper;
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
component!.update();
|
||||
expect(DashboardAppNoDataPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('initialFilter is passed through if title is not provided', async () => {
|
||||
const props = makeDefaultProps();
|
||||
props.initialFilter = 'filterPassThrough';
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props }));
|
||||
});
|
||||
|
||||
component!.update();
|
||||
expect(DashboardListing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialFilter: 'filterPassThrough' }),
|
||||
expect.any(Object) // react context
|
||||
);
|
||||
});
|
||||
|
||||
test('When given a title that matches multiple dashboards, filter on the title', async () => {
|
||||
const title = 'search by title';
|
||||
const props = makeDefaultProps();
|
||||
props.title = title;
|
||||
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props }));
|
||||
});
|
||||
component!.update();
|
||||
|
||||
expect(props.redirectTo).not.toHaveBeenCalled();
|
||||
expect(DashboardListing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialFilter: 'search by title' }),
|
||||
expect.any(Object) // react context
|
||||
);
|
||||
});
|
||||
|
||||
test('When given a title that matches one dashboard, redirect to dashboard', async () => {
|
||||
const title = 'search by title';
|
||||
const props = makeDefaultProps();
|
||||
props.title = title;
|
||||
(
|
||||
pluginServices.getServices().dashboardSavedObject.findDashboards.findByTitle as jest.Mock
|
||||
).mockResolvedValue({ id: 'you_found_me' });
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props }));
|
||||
});
|
||||
component!.update();
|
||||
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||
destination: 'dashboard',
|
||||
id: 'you_found_me',
|
||||
useReplace: true,
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
|
||||
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
import {
|
||||
DashboardAppNoDataPage,
|
||||
isDashboardAppInNoDataState,
|
||||
} from '../no_data/dashboard_app_no_data';
|
||||
import { DashboardRedirect } from '../types';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { getDashboardBreadcrumb } from '../_dashboard_app_strings';
|
||||
import { getDashboardListItemLink } from './get_dashboard_list_item_link';
|
||||
import { DashboardListing } from '../../dashboard_listing/dashboard_listing';
|
||||
|
||||
export interface DashboardListingPageProps {
|
||||
kbnUrlStateStorage: IKbnUrlStateStorage;
|
||||
redirectTo: DashboardRedirect;
|
||||
initialFilter?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DashboardListingPage = ({
|
||||
title,
|
||||
redirectTo,
|
||||
initialFilter,
|
||||
kbnUrlStateStorage,
|
||||
}: DashboardListingPageProps) => {
|
||||
const {
|
||||
data: { query },
|
||||
chrome: { setBreadcrumbs },
|
||||
dashboardSavedObject: { findDashboards },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
const isInNoDataState = await isDashboardAppInNoDataState();
|
||||
if (isInNoDataState && isMounted) setShowNoDataPage(true);
|
||||
})();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{
|
||||
text: getDashboardBreadcrumb(),
|
||||
},
|
||||
]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
// syncs `_g` portion of url with query services
|
||||
const { stop: stopSyncingQueryServiceStateWithUrl } = syncGlobalQueryStateWithUrl(
|
||||
query,
|
||||
kbnUrlStateStorage
|
||||
);
|
||||
if (title) {
|
||||
findDashboards.findByTitle(title).then((result) => {
|
||||
if (!result) return;
|
||||
redirectTo({
|
||||
destination: 'dashboard',
|
||||
id: result.id,
|
||||
useReplace: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopSyncingQueryServiceStateWithUrl();
|
||||
};
|
||||
}, [title, redirectTo, query, kbnUrlStateStorage, findDashboards]);
|
||||
|
||||
const titleFilter = title ? `${title}` : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoDataPage && (
|
||||
<DashboardAppNoDataPage onDataViewCreated={() => setShowNoDataPage(false)} />
|
||||
)}
|
||||
{!showNoDataPage && (
|
||||
<DashboardListing
|
||||
useSessionStorageIntegration={true}
|
||||
initialFilter={initialFilter ?? titleFilter}
|
||||
goToDashboard={(id, viewMode) => {
|
||||
redirectTo({ destination: 'dashboard', id, editMode: viewMode === ViewMode.EDIT });
|
||||
}}
|
||||
getDashboardUrl={(id, timeRestore) => {
|
||||
return getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
};
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
|
||||
|
||||
/**
|
||||
* Mock Table List view. This dashboard component is a wrapper around the shared UX table List view. We
|
||||
* need to ensure we're passing down the correct props, but the table list view itself doesn't need to be rendered
|
||||
* in our tests because it is covered in its package.
|
||||
*/
|
||||
import { TableListView } from '@kbn/content-management-table-list';
|
||||
// import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
|
||||
jest.mock('@kbn/content-management-table-list', () => {
|
||||
const originalModule = jest.requireActual('@kbn/content-management-table-list');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
TableListViewKibanaProvider: jest.fn().mockImplementation(({ children }) => {
|
||||
return <>{children}</>;
|
||||
}),
|
||||
TableListView: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
function makeDefaultProps(): DashboardListingProps {
|
||||
return {
|
||||
goToDashboard: jest.fn(),
|
||||
getDashboardUrl: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function mountWith({ props: incomingProps }: { props?: Partial<DashboardListingProps> }) {
|
||||
const props = { ...makeDefaultProps(), ...incomingProps };
|
||||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return <I18nProvider>{children}</I18nProvider>;
|
||||
};
|
||||
const component = mount(<DashboardListing {...props} />, { wrappingComponent });
|
||||
return { component, props };
|
||||
}
|
||||
|
||||
test('initial filter is passed through', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({ props: { initialFilter: 'kibanana' } }));
|
||||
});
|
||||
component!.update();
|
||||
expect(TableListView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialFilter: 'kibanana' }),
|
||||
expect.any(Object) // react context
|
||||
);
|
||||
});
|
||||
|
||||
test('when showWriteControls is true, table list view is passed editing functions', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
component!.update();
|
||||
expect(TableListView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createItem: expect.any(Function),
|
||||
deleteItems: expect.any(Function),
|
||||
editItem: expect.any(Function),
|
||||
}),
|
||||
expect.any(Object) // react context
|
||||
);
|
||||
});
|
||||
|
||||
test('when showWriteControls is false, table list view is not passed editing functions', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
component!.update();
|
||||
expect(TableListView).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createItem: undefined,
|
||||
deleteItems: undefined,
|
||||
editItem: undefined,
|
||||
}),
|
||||
expect.any(Object) // react context
|
||||
);
|
||||
});
|
|
@ -0,0 +1,264 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
|
||||
import React, { PropsWithChildren, useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
TableListView,
|
||||
TableListViewKibanaDependencies,
|
||||
TableListViewKibanaProvider,
|
||||
type UserContentCommonSchema,
|
||||
} from '@kbn/content-management-table-list';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
|
||||
import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public';
|
||||
import type { SavedObjectsFindOptionsReference, SimpleSavedObject } from '@kbn/core/public';
|
||||
|
||||
import {
|
||||
SAVED_OBJECT_DELETE_TIME,
|
||||
SAVED_OBJECT_LOADED_TIME,
|
||||
DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
} from '../dashboard_constants';
|
||||
import {
|
||||
dashboardListingTableStrings,
|
||||
dashboardListingErrorStrings,
|
||||
} from './_dashboard_listing_strings';
|
||||
import { DashboardAttributes } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { confirmCreateWithUnsaved } from './confirm_overlays';
|
||||
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
|
||||
import { DashboardApplicationService } from '../services/application/types';
|
||||
import { DashboardListingEmptyPrompt } from './dashboard_listing_empty_prompt';
|
||||
|
||||
// because the type of `application.capabilities.advancedSettings` is so generic, the provider
|
||||
// requiring the `save` key to be part of it is causing type issues - so, creating a custom type
|
||||
type TableListViewApplicationService = DashboardApplicationService & {
|
||||
capabilities: { advancedSettings: { save: boolean } };
|
||||
};
|
||||
|
||||
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
|
||||
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
|
||||
|
||||
interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
|
||||
attributes: {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeRestore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const toTableListViewSavedObject = (
|
||||
savedObject: SimpleSavedObject<DashboardAttributes>
|
||||
): DashboardSavedObjectUserContent => {
|
||||
const { title, description, timeRestore } = savedObject.attributes;
|
||||
return {
|
||||
type: 'dashboard',
|
||||
id: savedObject.id,
|
||||
updatedAt: savedObject.updatedAt!,
|
||||
references: savedObject.references,
|
||||
attributes: {
|
||||
title,
|
||||
description,
|
||||
timeRestore,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type DashboardListingProps = PropsWithChildren<{
|
||||
initialFilter?: string;
|
||||
useSessionStorageIntegration?: boolean;
|
||||
goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void;
|
||||
getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string;
|
||||
}>;
|
||||
|
||||
export const DashboardListing = ({
|
||||
children,
|
||||
initialFilter,
|
||||
goToDashboard,
|
||||
getDashboardUrl,
|
||||
useSessionStorageIntegration,
|
||||
}: DashboardListingProps) => {
|
||||
const {
|
||||
application,
|
||||
notifications,
|
||||
overlays,
|
||||
http,
|
||||
savedObjectsTagging,
|
||||
dashboardSessionStorage,
|
||||
settings: { uiSettings },
|
||||
notifications: { toasts },
|
||||
coreContext: { executionContext },
|
||||
dashboardCapabilities: { showWriteControls },
|
||||
dashboardSavedObject: { findDashboards, savedObjectsClient },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
|
||||
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
|
||||
);
|
||||
|
||||
useExecutionContext(executionContext, {
|
||||
type: 'application',
|
||||
page: 'list',
|
||||
});
|
||||
|
||||
const listingLimit = uiSettings.get(SAVED_OBJECTS_LIMIT_SETTING);
|
||||
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
|
||||
|
||||
const createItem = useCallback(() => {
|
||||
if (useSessionStorageIntegration && dashboardSessionStorage.dashboardHasUnsavedEdits()) {
|
||||
confirmCreateWithUnsaved(() => {
|
||||
dashboardSessionStorage.clearState();
|
||||
goToDashboard();
|
||||
}, goToDashboard);
|
||||
return;
|
||||
}
|
||||
goToDashboard();
|
||||
}, [dashboardSessionStorage, goToDashboard, useSessionStorageIntegration]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
(
|
||||
searchTerm: string,
|
||||
{
|
||||
references,
|
||||
referencesToExclude,
|
||||
}: {
|
||||
references?: SavedObjectsFindOptionsReference[];
|
||||
referencesToExclude?: SavedObjectsFindOptionsReference[];
|
||||
} = {}
|
||||
) => {
|
||||
const searchStartTime = window.performance.now();
|
||||
return findDashboards
|
||||
.findSavedObjects({
|
||||
search: searchTerm,
|
||||
size: listingLimit,
|
||||
hasReference: references,
|
||||
hasNoReference: referencesToExclude,
|
||||
})
|
||||
.then(({ total, hits }) => {
|
||||
const searchEndTime = window.performance.now();
|
||||
const searchDuration = searchEndTime - searchStartTime;
|
||||
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
|
||||
eventName: SAVED_OBJECT_LOADED_TIME,
|
||||
duration: searchDuration,
|
||||
meta: {
|
||||
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
},
|
||||
});
|
||||
return {
|
||||
total,
|
||||
hits: hits.map(toTableListViewSavedObject),
|
||||
};
|
||||
});
|
||||
},
|
||||
[findDashboards, listingLimit]
|
||||
);
|
||||
|
||||
const deleteItems = useCallback(
|
||||
async (dashboardsToDelete: Array<{ id: string }>) => {
|
||||
try {
|
||||
const deleteStartTime = window.performance.now();
|
||||
|
||||
await Promise.all(
|
||||
dashboardsToDelete.map(({ id }) => {
|
||||
dashboardSessionStorage.clearState(id);
|
||||
return savedObjectsClient.delete(DASHBOARD_SAVED_OBJECT_TYPE, id);
|
||||
})
|
||||
);
|
||||
|
||||
const deleteDuration = window.performance.now() - deleteStartTime;
|
||||
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
|
||||
eventName: SAVED_OBJECT_DELETE_TIME,
|
||||
duration: deleteDuration,
|
||||
meta: {
|
||||
saved_object_type: DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
total: dashboardsToDelete.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(),
|
||||
});
|
||||
}
|
||||
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
|
||||
},
|
||||
[savedObjectsClient, dashboardSessionStorage, toasts]
|
||||
);
|
||||
|
||||
const editItem = useCallback(
|
||||
({ id }: { id: string | undefined }) => goToDashboard(id, ViewMode.EDIT),
|
||||
[goToDashboard]
|
||||
);
|
||||
const emptyPrompt = (
|
||||
<DashboardListingEmptyPrompt
|
||||
createItem={createItem}
|
||||
goToDashboard={goToDashboard}
|
||||
unsavedDashboardIds={unsavedDashboardIds}
|
||||
setUnsavedDashboardIds={setUnsavedDashboardIds}
|
||||
useSessionStorageIntegration={useSessionStorageIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<TableListViewKibanaProvider
|
||||
{...{
|
||||
core: {
|
||||
application: application as TableListViewApplicationService,
|
||||
notifications,
|
||||
overlays,
|
||||
http,
|
||||
},
|
||||
toMountPoint,
|
||||
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
|
||||
? ({
|
||||
ui: savedObjectsTagging,
|
||||
} as TableListViewKibanaDependencies['savedObjectsTagging'])
|
||||
: undefined,
|
||||
FormattedRelative,
|
||||
}}
|
||||
>
|
||||
<TableListView<DashboardSavedObjectUserContent>
|
||||
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
|
||||
getDashboardUrl(id, timeRestore)
|
||||
}
|
||||
deleteItems={!showWriteControls ? undefined : deleteItems}
|
||||
createItem={!showWriteControls ? undefined : createItem}
|
||||
editItem={!showWriteControls ? undefined : editItem}
|
||||
entityNamePlural={getEntityNamePlural()}
|
||||
tableListTitle={getTableListTitle()}
|
||||
headingId="dashboardListingHeading"
|
||||
initialPageSize={initialPageSize}
|
||||
initialFilter={initialFilter}
|
||||
entityName={getEntityName()}
|
||||
listingLimit={listingLimit}
|
||||
emptyPrompt={emptyPrompt}
|
||||
findItems={fetchItems}
|
||||
id="dashboard"
|
||||
>
|
||||
<>
|
||||
{children}
|
||||
<DashboardUnsavedListing
|
||||
goToDashboard={goToDashboard}
|
||||
unsavedDashboardIds={unsavedDashboardIds}
|
||||
refreshUnsavedDashboards={() =>
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</TableListView>
|
||||
</TableListViewKibanaProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default DashboardListing;
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
|
||||
import {
|
||||
DashboardListingEmptyPrompt,
|
||||
DashboardListingEmptyPromptProps,
|
||||
} from './dashboard_listing_empty_prompt';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
|
||||
|
||||
jest.mock('./confirm_overlays', () => {
|
||||
const originalModule = jest.requireActual('./confirm_overlays');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
confirmDiscardUnsavedChanges: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const makeDefaultProps = (): DashboardListingEmptyPromptProps => ({
|
||||
createItem: jest.fn(),
|
||||
unsavedDashboardIds: [],
|
||||
goToDashboard: jest.fn(),
|
||||
setUnsavedDashboardIds: jest.fn(),
|
||||
useSessionStorageIntegration: true,
|
||||
});
|
||||
|
||||
function mountWith({
|
||||
props: incomingProps,
|
||||
}: {
|
||||
props?: Partial<DashboardListingEmptyPromptProps>;
|
||||
}) {
|
||||
const props = { ...makeDefaultProps(), ...incomingProps };
|
||||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return <I18nProvider>{children}</I18nProvider>;
|
||||
};
|
||||
const component = mount(<DashboardListingEmptyPrompt {...props} />, { wrappingComponent });
|
||||
return { component, props };
|
||||
}
|
||||
|
||||
test('renders readonly empty prompt when showWriteControls is off', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = false;
|
||||
|
||||
let component: ReactWrapper;
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
component!.update();
|
||||
expect(component!.find('EuiLink').length).toBe(0);
|
||||
});
|
||||
|
||||
test('renders empty prompt with link when showWriteControls is on', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
|
||||
|
||||
let component: ReactWrapper;
|
||||
await act(async () => {
|
||||
({ component } = mountWith({}));
|
||||
});
|
||||
|
||||
component!.update();
|
||||
expect(component!.find('EuiLink').length).toBe(1);
|
||||
});
|
||||
|
||||
test('renders continue button when no dashboards exist but one is in progress', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
|
||||
let component: ReactWrapper;
|
||||
let props: DashboardListingEmptyPromptProps;
|
||||
await act(async () => {
|
||||
({ component, props } = mountWith({
|
||||
props: { unsavedDashboardIds: ['newDashboard'], useSessionStorageIntegration: true },
|
||||
}));
|
||||
});
|
||||
component!.update();
|
||||
await act(async () => {
|
||||
// EuiButton is used for the Continue button
|
||||
const continueButton = component!.find('EuiButton');
|
||||
expect(continueButton.length).toBe(1);
|
||||
continueButton.find('button').simulate('click');
|
||||
});
|
||||
expect(props!.goToDashboard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders discard button when no dashboards exist but one is in progress', async () => {
|
||||
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
|
||||
let component: ReactWrapper;
|
||||
await act(async () => {
|
||||
({ component } = mountWith({
|
||||
props: { unsavedDashboardIds: ['coolId'], useSessionStorageIntegration: true },
|
||||
}));
|
||||
});
|
||||
component!.update();
|
||||
await act(async () => {
|
||||
// EuiButtonEmpty is used for the discard button
|
||||
component!.find('EuiButtonEmpty').simulate('click');
|
||||
});
|
||||
expect(confirmDiscardUnsavedChanges).toHaveBeenCalled();
|
||||
});
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiLink,
|
||||
EuiButton,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiButtonEmpty,
|
||||
EuiEmptyPrompt,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
noItemsStrings,
|
||||
getNewDashboardTitle,
|
||||
dashboardUnsavedListingStrings,
|
||||
} from './_dashboard_listing_strings';
|
||||
import { DashboardListingProps } from './dashboard_listing';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { confirmDiscardUnsavedChanges } from './confirm_overlays';
|
||||
import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service';
|
||||
|
||||
export interface DashboardListingEmptyPromptProps {
|
||||
createItem: () => void;
|
||||
unsavedDashboardIds: string[];
|
||||
goToDashboard: DashboardListingProps['goToDashboard'];
|
||||
setUnsavedDashboardIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
useSessionStorageIntegration: DashboardListingProps['useSessionStorageIntegration'];
|
||||
}
|
||||
|
||||
export const DashboardListingEmptyPrompt = ({
|
||||
useSessionStorageIntegration,
|
||||
setUnsavedDashboardIds,
|
||||
unsavedDashboardIds,
|
||||
goToDashboard,
|
||||
createItem,
|
||||
}: DashboardListingEmptyPromptProps) => {
|
||||
const {
|
||||
application,
|
||||
dashboardSessionStorage,
|
||||
dashboardCapabilities: { showWriteControls },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
const isEditingFirstDashboard = useMemo(
|
||||
() => useSessionStorageIntegration && unsavedDashboardIds.length === 1,
|
||||
[unsavedDashboardIds.length, useSessionStorageIntegration]
|
||||
);
|
||||
|
||||
const getEmptyAction = useCallback(() => {
|
||||
if (!isEditingFirstDashboard) {
|
||||
return (
|
||||
<EuiButton onClick={createItem} fill iconType="plusInCircle" data-test-subj="newItemButton">
|
||||
{noItemsStrings.getCreateNewDashboardText()}
|
||||
</EuiButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
color="danger"
|
||||
onClick={() =>
|
||||
confirmDiscardUnsavedChanges(() => {
|
||||
dashboardSessionStorage.clearState(DASHBOARD_PANELS_UNSAVED_ID);
|
||||
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
|
||||
})
|
||||
}
|
||||
data-test-subj="discardDashboardPromptButton"
|
||||
aria-label={dashboardUnsavedListingStrings.getDiscardAriaLabel(getNewDashboardTitle())}
|
||||
>
|
||||
{dashboardUnsavedListingStrings.getDiscardTitle()}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
iconType="pencil"
|
||||
color="primary"
|
||||
data-test-subj="newItemButton"
|
||||
onClick={() => goToDashboard()}
|
||||
aria-label={dashboardUnsavedListingStrings.getEditAriaLabel(getNewDashboardTitle())}
|
||||
>
|
||||
{dashboardUnsavedListingStrings.getEditTitle()}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}, [
|
||||
dashboardSessionStorage,
|
||||
isEditingFirstDashboard,
|
||||
setUnsavedDashboardIds,
|
||||
goToDashboard,
|
||||
createItem,
|
||||
]);
|
||||
|
||||
if (!showWriteControls) {
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="glasses"
|
||||
title={
|
||||
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
|
||||
{noItemsStrings.getReadonlyTitle()}
|
||||
</h1>
|
||||
}
|
||||
body={<p>{noItemsStrings.getReadonlyBody()}</p>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="dashboardApp"
|
||||
title={
|
||||
<h1 id="dashboardListingHeading" data-test-subj="emptyListPrompt">
|
||||
{isEditingFirstDashboard
|
||||
? noItemsStrings.getReadEditInProgressTitle()
|
||||
: noItemsStrings.getReadEditTitle()}
|
||||
</h1>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
<p>{noItemsStrings.getReadEditDashboardDescription()}</p>
|
||||
{!isEditingFirstDashboard && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="dashboard.listing.createNewDashboard.newToKibanaDescription"
|
||||
defaultMessage="New to Kibana? {sampleDataInstallLink} to take a test drive."
|
||||
values={{
|
||||
sampleDataInstallLink: (
|
||||
<EuiLink
|
||||
onClick={() =>
|
||||
application.navigateToApp('home', {
|
||||
path: '#/tutorial_directory/sampleData',
|
||||
})
|
||||
}
|
||||
>
|
||||
{noItemsStrings.getSampleDataLinkText()}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={getEmptyAction()}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -12,18 +12,19 @@ import { I18nProvider } from '@kbn/i18n-react';
|
|||
import { waitFor } from '@testing-library/react';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { DashboardUnsavedListing, DashboardUnsavedListingProps } from './dashboard_unsaved_listing';
|
||||
import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_storage/dashboard_session_storage_service';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { DASHBOARD_PANELS_UNSAVED_ID } from '../services/dashboard_session_storage/dashboard_session_storage_service';
|
||||
import { ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
const makeDefaultProps = (): DashboardUnsavedListingProps => ({
|
||||
redirectTo: jest.fn(),
|
||||
goToDashboard: jest.fn(),
|
||||
unsavedDashboardIds: ['dashboardUnsavedOne', 'dashboardUnsavedTwo', 'dashboardUnsavedThree'],
|
||||
refreshUnsavedDashboards: jest.fn(),
|
||||
});
|
||||
|
||||
function mountWith({ props: incomingProps }: { props?: DashboardUnsavedListingProps }) {
|
||||
const props = incomingProps ?? makeDefaultProps();
|
||||
function mountWith({ props: incomingProps }: { props?: Partial<DashboardUnsavedListingProps> }) {
|
||||
const props = { ...makeDefaultProps(), ...incomingProps };
|
||||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
|
@ -62,11 +63,7 @@ describe('Unsaved listing', () => {
|
|||
expect(getEditButton().length).toEqual(1);
|
||||
});
|
||||
getEditButton().simulate('click');
|
||||
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||
destination: 'dashboard',
|
||||
id: 'dashboardUnsavedOne',
|
||||
editMode: true,
|
||||
});
|
||||
expect(props.goToDashboard).toHaveBeenCalledWith('dashboardUnsavedOne', ViewMode.EDIT);
|
||||
});
|
||||
|
||||
it('Redirects to new dashboard when continue editing clicked', async () => {
|
||||
|
@ -79,11 +76,7 @@ describe('Unsaved listing', () => {
|
|||
expect(getEditButton().length).toBe(1);
|
||||
});
|
||||
getEditButton().simulate('click');
|
||||
expect(props.redirectTo).toHaveBeenCalledWith({
|
||||
destination: 'dashboard',
|
||||
id: undefined,
|
||||
editMode: true,
|
||||
});
|
||||
expect(props.goToDashboard).toHaveBeenCalledWith(undefined, ViewMode.EDIT);
|
||||
});
|
||||
|
||||
it('Shows a warning then clears changes when delete unsaved changes is pressed', async () => {
|
|
@ -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(
|
36
src/plugins/dashboard/public/dashboard_listing/index.tsx
Normal file
36
src/plugins/dashboard/public/dashboard_listing/index.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
|
||||
|
||||
import { servicesReady } from '../plugin';
|
||||
import { DashboardListingProps } from './dashboard_listing';
|
||||
|
||||
const ListingTableLoadingIndicator = () => {
|
||||
return <EuiEmptyPrompt icon={<EuiLoadingSpinner size="l" />} />;
|
||||
};
|
||||
|
||||
const LazyDashboardListing = React.lazy(() =>
|
||||
(async () => {
|
||||
const modulePromise = import('./dashboard_listing');
|
||||
const [module] = await Promise.all([modulePromise, servicesReady]);
|
||||
|
||||
return {
|
||||
default: module.DashboardListing,
|
||||
};
|
||||
})().then((module) => module)
|
||||
);
|
||||
|
||||
export const DashboardListingTable = (props: DashboardListingProps) => {
|
||||
return (
|
||||
<Suspense fallback={<ListingTableLoadingIndicator />}>
|
||||
<LazyDashboardListing {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -62,8 +62,8 @@ import {
|
|||
LEGACY_DASHBOARD_APP_ID,
|
||||
SEARCH_SESSION_ID,
|
||||
} from './dashboard_constants';
|
||||
import { PlaceholderEmbeddableFactory } from './placeholder_embeddable';
|
||||
import { DashboardMountContextProps } from './dashboard_app/types';
|
||||
import { PlaceholderEmbeddableFactory } from './placeholder_embeddable';
|
||||
import type { FindDashboardsService } from './services/dashboard_saved_object/types';
|
||||
|
||||
export interface DashboardFeatureFlagConfig {
|
||||
|
@ -114,6 +114,9 @@ export interface DashboardStart {
|
|||
findDashboardsService: () => Promise<FindDashboardsService>;
|
||||
}
|
||||
|
||||
export let resolveServicesReady: () => void;
|
||||
export const servicesReady = new Promise<void>((resolve) => (resolveServicesReady = resolve));
|
||||
|
||||
export class DashboardPlugin
|
||||
implements
|
||||
Plugin<DashboardSetup, DashboardStart, DashboardSetupDependencies, DashboardStartDependencies>
|
||||
|
@ -133,6 +136,7 @@ export class DashboardPlugin
|
|||
) {
|
||||
const { registry, pluginServices } = await import('./services/plugin_services');
|
||||
pluginServices.setRegistry(registry.start({ coreStart, startPlugins, initContext }));
|
||||
resolveServicesReady();
|
||||
}
|
||||
|
||||
public setup(
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue