Skip to content

Commit

Permalink
[SecuritySolution] Dashboard listing UI (elastic#160540)
Browse files Browse the repository at this point in the history
## Summary


1. Align dashboard listing UI with Kibana dashboard.
2. `Security Solution` tags are selected by default and removable by
users.

**Prerequisite:**
This PR is waiting for elastic#160871 to
be merged


**Steps to verify:**
1. Visit Security > Dashboards, and create a dashboard from this page.
2. Back to Security Dashboards page, you should see the dashboard you
just created and Security Solution tag should be selected by default in
the tag filters.
3. Open the tag options, click the Security Solution tag. Observe that
it should be removable, and it should display all the dashboards you
have in the table.

**Known issues:**
elastic#160540 (comment)

**Before:**

<img width="2545" alt="Screenshot 2023-06-27 at 09 24 19"
src="https://github.com/elastic/kibana/assets/6295984/bc0fa0b1-96ad-43b0-afc1-48444dfb5691">


**After:**
<img width="2543" alt="Screenshot 2023-06-27 at 09 22 21"
src="https://github.com/elastic/kibana/assets/6295984/82d0a868-bda2-431f-b0b5-9cbc34d3ae71">


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <[email protected]>
Co-authored-by: Pablo Neves Machado <[email protected]>
  • Loading branch information
3 people authored and Devon Thomson committed Aug 1, 2023
1 parent ac13b14 commit 9cfe96a
Show file tree
Hide file tree
Showing 18 changed files with 968 additions and 290 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import { mount, ReactWrapper } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';

import { pluginServices } from '../services/plugin_services';
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
import { DashboardListing } 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-view';
import { DashboardListingProps } from './types';
// import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view';
jest.mock('@kbn/content-management-table-list-view-table', () => {
const originalModule = jest.requireActual('@kbn/content-management-table-list-view-table');
Expand Down
206 changes: 19 additions & 187 deletions src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,73 +7,25 @@
*/

import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
import React, { PropsWithChildren, useCallback, useMemo, useState } from 'react';
import React, { useMemo } from 'react';

import {
type TableListViewKibanaDependencies,
TableListViewKibanaProvider,
type UserContentCommonSchema,
} from '@kbn/content-management-table-list-view-table';
import { TableListView } from '@kbn/content-management-table-list-view';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';

import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public';

import {
DASHBOARD_CONTENT_ID,
SAVED_OBJECT_DELETE_TIME,
SAVED_OBJECT_LOADED_TIME,
} from '../dashboard_constants';
import {
dashboardListingTableStrings,
dashboardListingErrorStrings,
} from './_dashboard_listing_strings';
import { pluginServices } from '../services/plugin_services';
import { confirmCreateWithUnsaved } from './confirm_overlays';
import { DashboardItem } from '../../common/content_management';
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 = (hit: DashboardItem): DashboardSavedObjectUserContent => {
const { title, description, timeRestore } = hit.attributes;
return {
type: 'dashboard',
id: hit.id,
updatedAt: hit.updatedAt!,
references: hit.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;
}>;
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
import { useDashboardListingTable } from './hooks/use_dashboard_listing_table';
import {
DashboardListingProps,
DashboardSavedObjectUserContent,
TableListViewApplicationService,
} from './types';

export const DashboardListing = ({
children,
Expand All @@ -89,123 +41,22 @@ export const DashboardListing = ({
http,
chrome: { theme },
savedObjectsTagging,
dashboardSessionStorage,
settings: { uiSettings },
notifications: { toasts },

coreContext: { executionContext },
dashboardCapabilities: { showWriteControls },
dashboardContentManagement: { findDashboards, deleteDashboards },
} = 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
.search({
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_CONTENT_ID,
},
});
return {
total,
hits: hits.map(toTableListViewSavedObject),
};
});
},
[findDashboards, listingLimit]
);

const deleteItems = useCallback(
async (dashboardsToDelete: Array<{ id: string }>) => {
try {
const deleteStartTime = window.performance.now();

await deleteDashboards(
dashboardsToDelete.map(({ id }) => {
dashboardSessionStorage.clearState(id);
return id;
})
);

const deleteDuration = window.performance.now() - deleteStartTime;
reportPerformanceMetricEvent(pluginServices.getServices().analytics, {
eventName: SAVED_OBJECT_DELETE_TIME,
duration: deleteDuration,
meta: {
saved_object_type: DASHBOARD_CONTENT_ID,
total: dashboardsToDelete.length,
},
});
} catch (error) {
toasts.addError(error, {
title: dashboardListingErrorStrings.getErrorDeletingDashboardToast(),
});
}

setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges());
},
[dashboardSessionStorage, deleteDashboards, 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;
const { unsavedDashboardIds, refreshUnsavedDashboards, tableListViewTableProps } =
useDashboardListingTable({
goToDashboard,
getDashboardUrl,
useSessionStorageIntegration,
initialFilter,
});

const savedObjectsTaggingFakePlugin = useMemo(() => {
return savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
Expand All @@ -231,32 +82,13 @@ export const DashboardListing = ({
FormattedRelative,
}}
>
<TableListView<DashboardSavedObjectUserContent>
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
getDashboardUrl(id, timeRestore)
}
deleteItems={!showWriteControls ? undefined : deleteItems}
createItem={!showWriteControls ? undefined : createItem}
editItem={!showWriteControls ? undefined : editItem}
entityNamePlural={getEntityNamePlural()}
title={getTableListTitle()}
headingId="dashboardListingHeading"
initialPageSize={initialPageSize}
initialFilter={initialFilter}
entityName={getEntityName()}
listingLimit={listingLimit}
emptyPrompt={emptyPrompt}
findItems={fetchItems}
id="dashboard"
>
<TableListView<DashboardSavedObjectUserContent> {...tableListViewTableProps}>
<>
{children}
<DashboardUnsavedListing
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={() =>
setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges())
}
refreshUnsavedDashboards={refreshUnsavedDashboards}
/>
</>
</TableListView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const makeDefaultProps = (): DashboardListingEmptyPromptProps => ({
goToDashboard: jest.fn(),
setUnsavedDashboardIds: jest.fn(),
useSessionStorageIntegration: true,
disableCreateDashboardButton: false,
});

function mountWith({
Expand Down Expand Up @@ -75,6 +76,21 @@ test('renders empty prompt with link when showWriteControls is on', async () =>
expect(component!.find('EuiLink').length).toBe(1);
});

test('renders disabled action button when disableCreateDashboardButton is true', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;

let component: ReactWrapper;
await act(async () => {
({ component } = mountWith({ props: { disableCreateDashboardButton: true } }));
});

component!.update();

expect(component!.find(`[data-test-subj="newItemButton"]`).first().prop('disabled')).toEqual(
true
);
});

test('renders continue button when no dashboards exist but one is in progress', async () => {
pluginServices.getServices().dashboardCapabilities.showWriteControls = true;
let component: ReactWrapper;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import {
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';
import { DashboardListingProps } from './types';

export interface DashboardListingEmptyPromptProps {
createItem: () => void;
disableCreateDashboardButton?: boolean;
unsavedDashboardIds: string[];
goToDashboard: DashboardListingProps['goToDashboard'];
setUnsavedDashboardIds: React.Dispatch<React.SetStateAction<string[]>>;
Expand All @@ -41,6 +42,7 @@ export const DashboardListingEmptyPrompt = ({
unsavedDashboardIds,
goToDashboard,
createItem,
disableCreateDashboardButton,
}: DashboardListingEmptyPromptProps) => {
const {
application,
Expand All @@ -56,7 +58,13 @@ export const DashboardListingEmptyPrompt = ({
const getEmptyAction = useCallback(() => {
if (!isEditingFirstDashboard) {
return (
<EuiButton onClick={createItem} fill iconType="plusInCircle" data-test-subj="newItemButton">
<EuiButton
onClick={createItem}
fill
iconType="plusInCircle"
data-test-subj="newItemButton"
disabled={disableCreateDashboardButton}
>
{noItemsStrings.getCreateNewDashboardText()}
</EuiButton>
);
Expand Down Expand Up @@ -94,11 +102,12 @@ export const DashboardListingEmptyPrompt = ({
</EuiFlexGroup>
);
}, [
dashboardSessionStorage,
isEditingFirstDashboard,
createItem,
disableCreateDashboardButton,
dashboardSessionStorage,
setUnsavedDashboardIds,
goToDashboard,
createItem,
]);

if (!showWriteControls) {
Expand Down
Loading

0 comments on commit 9cfe96a

Please sign in to comment.