[SecuritySolution] Dashboard listing UI (#160540)

## 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 https://github.com/elastic/kibana/pull/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:**
https://github.com/elastic/kibana/pull/160540#issuecomment-1610395834

**Before:**

<img width="2545" alt="Screenshot 2023-06-27 at 09 24 19"
src="bc0fa0b1-96ad-43b0-afc1-48444dfb5691">


**After:**
<img width="2543" alt="Screenshot 2023-06-27 at 09 22 21"
src="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 <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Pablo Neves Machado <pablo.nevesmachado@elastic.co>
This commit is contained in:
Angela Chuang 2023-07-24 14:51:27 +01:00 committed by GitHub
parent d2825d92bb
commit 50a9e13035
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 970 additions and 292 deletions

View file

@ -12,7 +12,7 @@ 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
@ -20,6 +20,7 @@ import { DashboardListing, DashboardListingProps } from './dashboard_listing';
* 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');

View file

@ -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 { useDashboardListingTable } from './hooks/use_dashboard_listing_table';
import {
DashboardListingProps,
DashboardSavedObjectUserContent,
TableListViewApplicationService,
} from './types';
export const DashboardListing = ({
children,
@ -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()
);
coreContext: { executionContext },
} = pluginServices.getServices();
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
@ -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>

View file

@ -34,6 +34,7 @@ const makeDefaultProps = (): DashboardListingEmptyPromptProps => ({
goToDashboard: jest.fn(),
setUnsavedDashboardIds: jest.fn(),
useSessionStorageIntegration: true,
disableCreateDashboardButton: false,
});
function mountWith({
@ -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;

View file

@ -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[]>>;
@ -41,6 +42,7 @@ export const DashboardListingEmptyPrompt = ({
unsavedDashboardIds,
goToDashboard,
createItem,
disableCreateDashboardButton,
}: DashboardListingEmptyPromptProps) => {
const {
application,
@ -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>
);
@ -94,11 +102,12 @@ export const DashboardListingEmptyPrompt = ({
</EuiFlexGroup>
);
}, [
dashboardSessionStorage,
isEditingFirstDashboard,
createItem,
disableCreateDashboardButton,
dashboardSessionStorage,
setUnsavedDashboardIds,
goToDashboard,
createItem,
]);
if (!showWriteControls) {

View file

@ -0,0 +1,112 @@
/*
* 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, { useMemo } from 'react';
import {
type TableListViewKibanaDependencies,
TableListViewKibanaProvider,
TableListViewTable,
} from '@kbn/content-management-table-list-view-table';
import { toMountPoint, useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { pluginServices } from '../services/plugin_services';
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
import { useDashboardListingTable } from './hooks/use_dashboard_listing_table';
import {
DashboardListingProps,
DashboardSavedObjectUserContent,
TableListViewApplicationService,
} from './types';
export const DashboardListingTable = ({
disableCreateDashboardButton,
initialFilter,
goToDashboard,
getDashboardUrl,
useSessionStorageIntegration,
urlStateEnabled,
}: DashboardListingProps) => {
const {
application,
notifications,
overlays,
http,
savedObjectsTagging,
coreContext: { executionContext },
chrome: { theme },
} = pluginServices.getServices();
useExecutionContext(executionContext, {
type: 'application',
page: 'list',
});
const {
unsavedDashboardIds,
refreshUnsavedDashboards,
tableListViewTableProps: { title: tableCaption, ...tableListViewTable },
} = useDashboardListingTable({
disableCreateDashboardButton,
goToDashboard,
getDashboardUrl,
urlStateEnabled,
useSessionStorageIntegration,
initialFilter,
});
const savedObjectsTaggingFakePlugin = useMemo(
() =>
savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
? ({
ui: savedObjectsTagging,
} as TableListViewKibanaDependencies['savedObjectsTagging'])
: undefined,
[savedObjectsTagging]
);
const core = useMemo(
() => ({
application: application as TableListViewApplicationService,
notifications,
overlays,
http,
theme,
}),
[application, notifications, overlays, http, theme]
);
return (
<I18nProvider>
<TableListViewKibanaProvider
core={core}
toMountPoint={toMountPoint}
savedObjectsTagging={savedObjectsTaggingFakePlugin}
FormattedRelative={FormattedRelative}
>
<>
<DashboardUnsavedListing
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={refreshUnsavedDashboards}
/>
<TableListViewTable<DashboardSavedObjectUserContent>
tableCaption={tableCaption}
{...tableListViewTable}
/>
</>
</TableListViewKibanaProvider>
</I18nProvider>
);
};
// eslint-disable-next-line import/no-default-export
export default DashboardListingTable;

View file

@ -0,0 +1,247 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { useDashboardListingTable } from './use_dashboard_listing_table';
import { pluginServices } from '../../services/plugin_services';
import { confirmCreateWithUnsaved } from '../confirm_overlays';
import { DashboardSavedObjectUserContent } from '../types';
const clearStateMock = jest.fn();
const getDashboardUrl = jest.fn();
const goToDashboard = jest.fn();
const deleteDashboards = jest.fn().mockResolvedValue(true);
const getUiSettingsMock = jest.fn().mockImplementation((key) => {
if (key === 'savedObjects:listingLimit') {
return 20;
}
if (key === 'savedObjects:perPage') {
return 5;
}
return null;
});
const getPluginServices = pluginServices.getServices();
jest.mock('@kbn/ebt-tools', () => ({
reportPerformanceMetricEvent: jest.fn(),
}));
jest.mock('../confirm_overlays', () => ({
confirmCreateWithUnsaved: jest.fn().mockImplementation((fn) => fn()),
}));
jest.mock('../_dashboard_listing_strings', () => ({
dashboardListingTableStrings: {
getEntityName: jest.fn().mockReturnValue('Dashboard'),
getTableListTitle: jest.fn().mockReturnValue('Dashboard List'),
getEntityNamePlural: jest.fn().mockReturnValue('Dashboards'),
},
}));
describe('useDashboardListingTable', () => {
beforeEach(() => {
jest.clearAllMocks();
getPluginServices.dashboardSessionStorage.dashboardHasUnsavedEdits = jest
.fn()
.mockReturnValue(true);
getPluginServices.dashboardSessionStorage.getDashboardIdsWithUnsavedChanges = jest
.fn()
.mockReturnValue([]);
getPluginServices.dashboardSessionStorage.clearState = clearStateMock;
getPluginServices.dashboardCapabilities.showWriteControls = true;
getPluginServices.dashboardContentManagement.deleteDashboards = deleteDashboards;
getPluginServices.settings.uiSettings.get = getUiSettingsMock;
getPluginServices.notifications.toasts.addError = jest.fn();
});
test('should return the correct initial hasInitialFetchReturned state', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.hasInitialFetchReturned).toBe(false);
});
test('should return the correct initial pageDataTestSubject state', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.pageDataTestSubject).toBeUndefined();
});
test('should return the correct refreshUnsavedDashboards function', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(typeof result.current.refreshUnsavedDashboards).toBe('function');
});
test('should return the correct initial unsavedDashboardIds state', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.unsavedDashboardIds).toEqual([]);
});
test('should return the correct tableListViewTableProps', () => {
const initialFilter = 'myFilter';
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
initialFilter,
urlStateEnabled: false,
})
);
const tableListViewTableProps = result.current.tableListViewTableProps;
const expectedProps = {
createItem: expect.any(Function),
deleteItems: expect.any(Function),
editItem: expect.any(Function),
emptyPrompt: expect.any(Object),
entityName: 'Dashboard',
entityNamePlural: 'Dashboards',
findItems: expect.any(Function),
getDetailViewLink: expect.any(Function),
headingId: 'dashboardListingHeading',
id: expect.any(String),
initialFilter: 'myFilter',
initialPageSize: 5,
listingLimit: 20,
onFetchSuccess: expect.any(Function),
setPageDataTestSubject: expect.any(Function),
title: 'Dashboard List',
urlStateEnabled: false,
};
expect(tableListViewTableProps).toEqual(expectedProps);
});
test('should call deleteDashboards when deleteItems is called', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
act(() => {
result.current.tableListViewTableProps.deleteItems?.([
{ id: 'test-id' } as DashboardSavedObjectUserContent,
]);
});
expect(deleteDashboards).toHaveBeenCalled();
});
test('should call goToDashboard when editItem is called', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
act(() => {
result.current.tableListViewTableProps.editItem?.({
id: 'test-id',
} as DashboardSavedObjectUserContent);
});
expect(goToDashboard).toHaveBeenCalled();
});
test('should call goToDashboard when createItem is called without unsaved changes', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
act(() => {
result.current.tableListViewTableProps.createItem?.();
});
expect(goToDashboard).toHaveBeenCalled();
});
test('should call confirmCreateWithUnsaved and clear state when createItem is called with unsaved changes', () => {
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
useSessionStorageIntegration: true,
})
);
act(() => {
result.current.tableListViewTableProps.createItem?.();
});
expect(confirmCreateWithUnsaved).toHaveBeenCalled();
expect(clearStateMock).toHaveBeenCalled();
expect(goToDashboard).toHaveBeenCalled();
});
test('createItem should be undefined when showWriteControls equals false', () => {
getPluginServices.dashboardCapabilities.showWriteControls = false;
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.tableListViewTableProps.createItem).toBeUndefined();
});
test('deleteItems should be undefined when showWriteControls equals false', () => {
getPluginServices.dashboardCapabilities.showWriteControls = false;
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.tableListViewTableProps.deleteItems).toBeUndefined();
});
test('editItem should be undefined when showWriteControls equals false', () => {
getPluginServices.dashboardCapabilities.showWriteControls = false;
const { result } = renderHook(() =>
useDashboardListingTable({
getDashboardUrl,
goToDashboard,
})
);
expect(result.current.tableListViewTableProps.editItem).toBeUndefined();
});
});

View file

@ -0,0 +1,273 @@
/*
* 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, { useCallback, useState, useMemo } from 'react';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { TableListViewTableProps } from '@kbn/content-management-table-list-view-table';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardListingEmptyPrompt } from '../dashboard_listing_empty_prompt';
import { pluginServices } from '../../services/plugin_services';
import {
DASHBOARD_CONTENT_ID,
SAVED_OBJECT_DELETE_TIME,
SAVED_OBJECT_LOADED_TIME,
} from '../../dashboard_constants';
import { DashboardItem } from '../../../common/content_management';
import {
dashboardListingErrorStrings,
dashboardListingTableStrings,
} from '../_dashboard_listing_strings';
import { confirmCreateWithUnsaved } from '../confirm_overlays';
import { DashboardSavedObjectUserContent } from '../types';
type GetDetailViewLink =
TableListViewTableProps<DashboardSavedObjectUserContent>['getDetailViewLink'];
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
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,
},
};
};
interface UseDashboardListingTableReturnType {
hasInitialFetchReturned: boolean;
pageDataTestSubject: string | undefined;
refreshUnsavedDashboards: () => void;
tableListViewTableProps: Omit<
TableListViewTableProps<DashboardSavedObjectUserContent>,
'tableCaption'
> & { title: string };
unsavedDashboardIds: string[];
}
export const useDashboardListingTable = ({
dashboardListingId = 'dashboard',
disableCreateDashboardButton,
getDashboardUrl,
goToDashboard,
headingId = 'dashboardListingHeading',
initialFilter,
urlStateEnabled,
useSessionStorageIntegration,
}: {
dashboardListingId?: string;
disableCreateDashboardButton?: boolean;
getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string;
goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void;
headingId?: string;
initialFilter?: string;
urlStateEnabled?: boolean;
useSessionStorageIntegration?: boolean;
}): UseDashboardListingTableReturnType => {
const {
dashboardSessionStorage,
dashboardCapabilities: { showWriteControls },
dashboardContentManagement: { findDashboards, deleteDashboards },
settings: { uiSettings },
notifications: { toasts },
} = pluginServices.getServices();
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTableStrings;
const title = getTableListTitle();
const entityName = getEntityName();
const entityNamePlural = getEntityNamePlural();
const [pageDataTestSubject, setPageDataTestSubject] = useState<string>();
const [hasInitialFetchReturned, setHasInitialFetchReturned] = useState(false);
const [unsavedDashboardIds, setUnsavedDashboardIds] = useState<string[]>(
dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()
);
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 emptyPrompt = useMemo(
() => (
<DashboardListingEmptyPrompt
createItem={createItem}
disableCreateDashboardButton={disableCreateDashboardButton}
goToDashboard={goToDashboard}
setUnsavedDashboardIds={setUnsavedDashboardIds}
unsavedDashboardIds={unsavedDashboardIds}
useSessionStorageIntegration={useSessionStorageIntegration}
/>
),
[
createItem,
disableCreateDashboardButton,
goToDashboard,
unsavedDashboardIds,
useSessionStorageIntegration,
]
);
const findItems = 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 onFetchSuccess = useCallback(() => {
if (!hasInitialFetchReturned) {
setHasInitialFetchReturned(true);
}
}, [hasInitialFetchReturned]);
const getDetailViewLink: GetDetailViewLink = useCallback(
({ id, attributes: { timeRestore } }) => getDashboardUrl(id, timeRestore),
[getDashboardUrl]
);
const tableListViewTableProps = useMemo(
() => ({
createItem: !showWriteControls ? undefined : createItem,
deleteItems: !showWriteControls ? undefined : deleteItems,
editItem: !showWriteControls ? undefined : editItem,
emptyPrompt,
entityName,
entityNamePlural,
findItems,
getDetailViewLink,
headingId,
id: dashboardListingId,
initialFilter,
initialPageSize,
listingLimit,
onFetchSuccess,
setPageDataTestSubject,
title,
urlStateEnabled,
}),
[
createItem,
dashboardListingId,
deleteItems,
editItem,
emptyPrompt,
entityName,
entityNamePlural,
findItems,
getDetailViewLink,
headingId,
initialFilter,
initialPageSize,
listingLimit,
onFetchSuccess,
showWriteControls,
title,
urlStateEnabled,
]
);
const refreshUnsavedDashboards = useCallback(
() => setUnsavedDashboardIds(dashboardSessionStorage.getDashboardIdsWithUnsavedChanges()),
[dashboardSessionStorage]
);
return {
hasInitialFetchReturned,
pageDataTestSubject,
refreshUnsavedDashboards,
tableListViewTableProps,
unsavedDashboardIds,
};
};

View file

@ -10,7 +10,7 @@ import React, { Suspense } from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
import { servicesReady } from '../plugin';
import { DashboardListingProps } from './dashboard_listing';
import { DashboardListingProps } from './types';
const ListingTableLoadingIndicator = () => {
return <EuiEmptyPrompt icon={<EuiLoadingSpinner size="l" />} />;
@ -18,11 +18,11 @@ const ListingTableLoadingIndicator = () => {
const LazyDashboardListing = React.lazy(() =>
(async () => {
const modulePromise = import('./dashboard_listing');
const modulePromise = import('./dashboard_listing_table');
const [module] = await Promise.all([modulePromise, servicesReady]);
return {
default: module.DashboardListing,
default: module.DashboardListingTable,
};
})().then((module) => module)
);

View file

@ -0,0 +1,34 @@
/*
* 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 { PropsWithChildren } from 'react';
import { type UserContentCommonSchema } from '@kbn/content-management-table-list-view-table';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardApplicationService } from '../services/application/types';
export type DashboardListingProps = PropsWithChildren<{
disableCreateDashboardButton?: boolean;
initialFilter?: string;
useSessionStorageIntegration?: boolean;
goToDashboard: (dashboardId?: string, viewMode?: ViewMode) => void;
getDashboardUrl: (dashboardId: string, usesTimeRestore: boolean) => string;
urlStateEnabled?: boolean;
}>;
// 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
export type TableListViewApplicationService = DashboardApplicationService & {
capabilities: { advancedSettings: { save: boolean } };
};
export interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
attributes: {
title: string;
description?: string;
timeRestore: boolean;
};
}

View file

@ -0,0 +1,13 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* Returns the hex representation of a random color (e.g `#F1B7E2`)
*/
export const getRandomColor = (): string => {
return `#${String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0')}`;
};

View file

@ -6,11 +6,23 @@
*/
export const MOCK_TAG_ID = 'securityTagId';
export const MOCK_TAG_NAME = 'test tag';
export const DEFAULT_TAGS_RESPONSE = [
{
id: MOCK_TAG_ID,
name: 'test tag',
attributes: {
name: MOCK_TAG_NAME,
description: 'test tag description',
color: '#2c7b82',
},
},
];
export const DEFAULT_CREATE_TAGS_RESPONSE = [
{
id: MOCK_TAG_ID,
name: MOCK_TAG_NAME,
description: 'test tag description',
color: '#2c7b82',
},
@ -21,4 +33,4 @@ export const getTagsByName = jest
.mockImplementation(() => Promise.resolve(DEFAULT_TAGS_RESPONSE));
export const createTag = jest
.fn()
.mockImplementation(() => Promise.resolve(DEFAULT_TAGS_RESPONSE[0]));
.mockImplementation(() => Promise.resolve(DEFAULT_CREATE_TAGS_RESPONSE[0]));

View file

@ -6,20 +6,29 @@
*/
import type { HttpSetup } from '@kbn/core/public';
import type { Tag } from '@kbn/saved-objects-tagging-plugin/public';
import type { TagAttributes } from '@kbn/saved-objects-tagging-plugin/common';
import type {
ITagsClient,
TagAttributes,
Tag as TagResponse,
} from '@kbn/saved-objects-tagging-plugin/common';
import { INTERNAL_TAGS_URL } from '../../../../common/constants';
export interface Tag {
id: string;
attributes: TagAttributes;
}
export const getTagsByName = (
{ http, tagName }: { http: HttpSetup; tagName: string },
abortSignal?: AbortSignal
): Promise<Tag[]> => http.get(INTERNAL_TAGS_URL, { query: { name: tagName }, signal: abortSignal });
export const createTag = (
{ http, tag }: { http: HttpSetup; tag: Omit<TagAttributes, 'color'> & { color?: string } },
abortSignal?: AbortSignal
): Promise<Tag> =>
http.put(INTERNAL_TAGS_URL, {
body: JSON.stringify(tag),
signal: abortSignal,
});
// Dashboard listing needs savedObjectsTaggingClient to work correctly with cache.
// https://github.com/elastic/kibana/issues/160723#issuecomment-1641904984
export const createTag = ({
savedObjectsTaggingClient,
tag,
}: {
savedObjectsTaggingClient: ITagsClient;
tag: TagAttributes;
}): Promise<TagResponse> => savedObjectsTaggingClient.create(tag);

View file

@ -14,13 +14,18 @@ import {
} from '../../../common/constants';
import { useKibana } from '../../common/lib/kibana';
import { useFetchSecurityTags } from './use_fetch_security_tags';
import { DEFAULT_TAGS_RESPONSE } from '../../common/containers/tags/__mocks__/api';
import type { ITagsClient } from '@kbn/saved-objects-tagging-plugin/common';
import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
jest.mock('../../common/lib/kibana');
jest.mock('../../../common/utils/get_ramdom_color', () => ({
getRandomColor: jest.fn().mockReturnValue('#FFFFFF'),
}));
const mockGet = jest.fn();
const mockPut = jest.fn();
const mockAbortSignal = {} as unknown as AbortSignal;
const mockCreateTag = jest.fn();
const renderUseCreateSecurityDashboardLink = () => renderHook(() => useFetchSecurityTags(), {});
const asyncRenderUseCreateSecurityDashboardLink = async () => {
@ -33,8 +38,10 @@ const asyncRenderUseCreateSecurityDashboardLink = async () => {
describe('useFetchSecurityTags', () => {
beforeAll(() => {
useKibana().services.http = { get: mockGet, put: mockPut } as unknown as HttpStart;
useKibana().services.http = { get: mockGet } as unknown as HttpStart;
useKibana().services.savedObjectsTagging = {
client: { create: mockCreateTag } as unknown as ITagsClient,
} as unknown as SavedObjectsTaggingApi;
global.AbortController = jest.fn().mockReturnValue({
abort: jest.fn(),
signal: mockAbortSignal,
@ -59,18 +66,24 @@ describe('useFetchSecurityTags', () => {
mockGet.mockResolvedValue([]);
await asyncRenderUseCreateSecurityDashboardLink();
expect(mockPut).toHaveBeenCalledWith(INTERNAL_TAGS_URL, {
body: JSON.stringify({ name: SECURITY_TAG_NAME, description: SECURITY_TAG_DESCRIPTION }),
signal: mockAbortSignal,
expect(mockCreateTag).toHaveBeenCalledWith({
name: SECURITY_TAG_NAME,
description: SECURITY_TAG_DESCRIPTION,
color: '#FFFFFF',
});
});
test('should return Security Solution tags', async () => {
const mockFoundTags = [{ id: 'tagId', name: 'Security Solution', description: '', color: '' }];
mockGet.mockResolvedValue(mockFoundTags);
mockGet.mockResolvedValue(DEFAULT_TAGS_RESPONSE);
const expected = DEFAULT_TAGS_RESPONSE.map((tag) => ({
id: tag.id,
type: 'tag',
...tag.attributes,
}));
const { result } = await asyncRenderUseCreateSecurityDashboardLink();
expect(mockPut).not.toHaveBeenCalled();
expect(result.current.tags).toEqual(expect.objectContaining(mockFoundTags));
expect(mockCreateTag).not.toHaveBeenCalled();
expect(result.current.tags).toEqual(expect.objectContaining(expected));
});
});

View file

@ -10,9 +10,10 @@ import { useKibana } from '../../common/lib/kibana';
import { createTag, getTagsByName } from '../../common/containers/tags/api';
import { REQUEST_NAMES, useFetch } from '../../common/hooks/use_fetch';
import { SECURITY_TAG_DESCRIPTION, SECURITY_TAG_NAME } from '../../../common/constants';
import { getRandomColor } from '../../../common/utils/get_ramdom_color';
export const useFetchSecurityTags = () => {
const { http } = useKibana().services;
const { http, savedObjectsTagging } = useKibana().services;
const tagCreated = useRef(false);
const {
@ -31,20 +32,31 @@ export const useFetchSecurityTags = () => {
} = useFetch(REQUEST_NAMES.SECURITY_CREATE_TAG, createTag);
useEffect(() => {
if (!isLoadingTags && !errorFetchTags && tags && tags.length === 0 && !tagCreated.current) {
if (
savedObjectsTagging &&
!isLoadingTags &&
!errorFetchTags &&
tags &&
tags.length === 0 &&
!tagCreated.current
) {
tagCreated.current = true;
fetchCreateTag({
http,
tag: { name: SECURITY_TAG_NAME, description: SECURITY_TAG_DESCRIPTION },
savedObjectsTaggingClient: savedObjectsTagging.client,
tag: {
name: SECURITY_TAG_NAME,
description: SECURITY_TAG_DESCRIPTION,
color: getRandomColor(),
},
});
}
}, [errorFetchTags, fetchCreateTag, http, isLoadingTags, tags]);
}, [errorFetchTags, fetchCreateTag, savedObjectsTagging, isLoadingTags, tags]);
const tagsResult = useMemo(() => {
if (tags?.length) {
return tags;
return tags.map((t) => ({ id: t.id, type: 'tag', ...t.attributes }));
}
return tag ? [tag] : undefined;
return tag ? [{ type: 'tag', ...tag }] : undefined;
}, [tags, tag]);
return {

View file

@ -9,8 +9,11 @@ import React from 'react';
import type { Tag } from '@kbn/saved-objects-tagging-plugin/common';
import { useFetchSecurityTags } from '../containers/use_fetch_security_tags';
export interface TagReference extends Tag {
type: string;
}
export interface DashboardContextType {
securityTags: Tag[] | null;
securityTags: TagReference[] | null;
}
const DashboardContext = React.createContext<DashboardContextType | null>({ securityTags: null });

View file

@ -5,20 +5,29 @@
* 2.0.
*/
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { SecurityPageName } from '../../../app/types';
import { TestProviders } from '../../../common/mock';
import { DashboardsLandingPage } from '.';
import type { NavigationLink } from '../../../common/links';
import { useCapabilities } from '../../../common/lib/kibana';
import * as telemetry from '../../../common/lib/telemetry';
import { DashboardListingTable } from '@kbn/dashboard-plugin/public';
import { MOCK_TAG_NAME } from '../../../common/containers/tags/__mocks__/api';
import { DashboardContextProvider } from '../../context/dashboard_context';
import { act } from 'react-dom/test-utils';
import type { NavigationLink } from '../../../common/links/types';
jest.mock('../../../common/containers/tags/api');
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null }));
jest.mock('../../components/dashboards_table', () => ({
DashboardsTable: () => <span data-test-subj="dashboardsTable" />,
}));
jest.mock('@kbn/dashboard-plugin/public', () => {
const actual = jest.requireActual('@kbn/dashboard-plugin/public');
return {
...actual,
DashboardListingTable: jest.fn().mockReturnValue(<span data-test-subj="dashboardsTable" />),
};
});
const DEFAULT_DASHBOARD_CAPABILITIES = { show: true, createNew: true };
const mockUseCapabilities = useCapabilities as jest.Mock;
@ -63,97 +72,122 @@ jest.mock('../../hooks/use_create_security_dashboard_link', () => {
};
});
const renderDashboardLanding = () => render(<DashboardsLandingPage />, { wrapper: TestProviders });
const TestComponent = () => (
<TestProviders>
<DashboardContextProvider>
<DashboardsLandingPage />
</DashboardContextProvider>
</TestProviders>
);
const renderDashboardLanding = async () => {
await act(async () => {
render(<TestComponent />);
});
};
describe('Dashboards landing', () => {
describe('Dashboards default links', () => {
it('should render items', () => {
const { queryByText } = renderDashboardLanding();
beforeEach(() => {
mockUseCapabilities.mockReturnValue(DEFAULT_DASHBOARD_CAPABILITIES);
mockUseCreateSecurityDashboard.mockReturnValue(CREATE_DASHBOARD_LINK);
});
expect(queryByText(OVERVIEW_ITEM_LABEL)).toBeInTheDocument();
expect(queryByText(DETECTION_RESPONSE_ITEM_LABEL)).toBeInTheDocument();
describe('Dashboards default links', () => {
it('should render items', async () => {
await renderDashboardLanding();
expect(screen.queryByText(OVERVIEW_ITEM_LABEL)).toBeInTheDocument();
expect(screen.queryByText(DETECTION_RESPONSE_ITEM_LABEL)).toBeInTheDocument();
});
it('should render items in the same order as defined', () => {
it('should render items in the same order as defined', async () => {
mockAppManageLink.mockReturnValueOnce({
...APP_DASHBOARD_LINKS,
});
const { queryAllByTestId } = renderDashboardLanding();
await renderDashboardLanding();
const renderedItems = queryAllByTestId('LandingImageCard-item');
const renderedItems = screen.queryAllByTestId('LandingImageCard-item');
expect(renderedItems[0]).toHaveTextContent(OVERVIEW_ITEM_LABEL);
expect(renderedItems[1]).toHaveTextContent(DETECTION_RESPONSE_ITEM_LABEL);
});
it('should not render items if all items filtered', () => {
mockAppManageLink.mockReturnValueOnce({
it('should not render items if all items filtered', async () => {
mockAppManageLink.mockReturnValue({
...APP_DASHBOARD_LINKS,
links: [],
});
const { queryByText } = renderDashboardLanding();
await renderDashboardLanding();
expect(queryByText(OVERVIEW_ITEM_LABEL)).not.toBeInTheDocument();
expect(queryByText(DETECTION_RESPONSE_ITEM_LABEL)).not.toBeInTheDocument();
expect(screen.queryByText(OVERVIEW_ITEM_LABEL)).not.toBeInTheDocument();
expect(screen.queryByText(DETECTION_RESPONSE_ITEM_LABEL)).not.toBeInTheDocument();
});
});
describe('Security Dashboards', () => {
it('should render dashboards table', () => {
const result = renderDashboardLanding();
it('should render dashboards table', async () => {
await renderDashboardLanding();
expect(result.getByTestId('dashboardsTable')).toBeInTheDocument();
expect(screen.getByTestId('dashboardsTable')).toBeInTheDocument();
});
it('should not render dashboards table if no read capability', () => {
mockUseCapabilities.mockReturnValueOnce({
it('should call DashboardListingTable with correct initialFilter', async () => {
await renderDashboardLanding();
expect((DashboardListingTable as jest.Mock).mock.calls[0][0].initialFilter).toEqual(
`tag:("${MOCK_TAG_NAME}")`
);
});
it('should not render dashboards table if no read capability', async () => {
mockUseCapabilities.mockReturnValue({
...DEFAULT_DASHBOARD_CAPABILITIES,
show: false,
});
const result = renderDashboardLanding();
await renderDashboardLanding();
expect(result.queryByTestId('dashboardsTable')).not.toBeInTheDocument();
expect(screen.queryByTestId('dashboardsTable')).not.toBeInTheDocument();
});
describe('Create Security Dashboard button', () => {
it('should render', () => {
const result = renderDashboardLanding();
it('should render', async () => {
await renderDashboardLanding();
expect(result.getByTestId('createDashboardButton')).toBeInTheDocument();
expect(screen.getByTestId('createDashboardButton')).toBeInTheDocument();
});
it('should not render if no write capability', () => {
mockUseCapabilities.mockReturnValueOnce({
it('should not render if no write capability', async () => {
mockUseCapabilities.mockReturnValue({
...DEFAULT_DASHBOARD_CAPABILITIES,
createNew: false,
});
const result = renderDashboardLanding();
await renderDashboardLanding();
expect(result.queryByTestId('createDashboardButton')).not.toBeInTheDocument();
expect(screen.queryByTestId('createDashboardButton')).not.toBeInTheDocument();
});
it('should be enabled when link loaded', () => {
const result = renderDashboardLanding();
it('should be enabled when link loaded', async () => {
await renderDashboardLanding();
expect(result.getByTestId('createDashboardButton')).not.toHaveAttribute('disabled');
expect(screen.getByTestId('createDashboardButton')).not.toHaveAttribute('disabled');
});
it('should be disabled when link is not loaded', () => {
mockUseCreateSecurityDashboard.mockReturnValueOnce({ isLoading: true, url: '' });
const result = renderDashboardLanding();
it('should be disabled when link is not loaded', async () => {
mockUseCreateSecurityDashboard.mockReturnValue({ isLoading: true, url: '' });
await renderDashboardLanding();
expect(result.getByTestId('createDashboardButton')).toHaveAttribute('disabled');
expect(screen.getByTestId('createDashboardButton')).toHaveAttribute('disabled');
});
it('should link to correct href', () => {
const result = renderDashboardLanding();
it('should link to correct href', async () => {
await renderDashboardLanding();
expect(result.getByTestId('createDashboardButton')).toHaveAttribute('href', URL);
expect(screen.getByTestId('createDashboardButton')).toHaveAttribute('href', URL);
});
it('should send telemetry', () => {
const result = renderDashboardLanding();
result.getByTestId('createDashboardButton').click();
it('should send telemetry', async () => {
await renderDashboardLanding();
screen.getByTestId('createDashboardButton').click();
expect(spyTrack).toHaveBeenCalledWith(
telemetry.METRIC_TYPE.CLICK,
telemetry.TELEMETRY_EVENT.CREATE_DASHBOARD

View file

@ -4,10 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui';
import React from 'react';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLoadingSpinner,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types';
import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public';
import { DashboardListingTable, LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public';
import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { LandingImageCards } from '../../../common/components/landing_links/landing_links_images';
@ -20,7 +28,25 @@ import * as i18n from './translations';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../common/lib/telemetry';
import { DASHBOARDS_PAGE_TITLE } from '../translations';
import { useCreateSecurityDashboardLink } from '../../hooks/use_create_security_dashboard_link';
import { DashboardsTable } from '../../components/dashboards_table';
import { useGetSecuritySolutionUrl } from '../../../common/components/link_to';
import type { TagReference } from '../../context/dashboard_context';
import { useSecurityTags } from '../../context/dashboard_context';
const getInitialFilterString = (securityTags: TagReference[] | null | undefined) => {
if (!securityTags) {
return;
}
const uniqueQuerySet = securityTags?.reduce<Set<string>>((acc, { name }) => {
const nameString = `"${name}"`;
if (name && !acc.has(nameString)) {
acc.add(nameString);
}
return acc;
}, new Set());
const query = [...uniqueQuerySet].join(' or');
return `tag:(${query})`;
};
const Header: React.FC<{ canCreateDashboard: boolean }> = ({ canCreateDashboard }) => {
const { isLoading, url } = useCreateSecurityDashboardLink();
@ -57,7 +83,36 @@ export const DashboardsLandingPage = () => {
const dashboardLinks = useRootNavLink(SecurityPageName.dashboards)?.links ?? [];
const { show: canReadDashboard, createNew: canCreateDashboard } =
useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID);
const { navigateTo } = useNavigateTo();
const getSecuritySolutionUrl = useGetSecuritySolutionUrl();
const getSecuritySolutionDashboardUrl = useCallback(
(id: string) =>
`${getSecuritySolutionUrl({
deepLinkId: SecurityPageName.dashboards,
path: id,
})}`,
[getSecuritySolutionUrl]
);
const { isLoading: loadingCreateDashboardUrl, url: createDashboardUrl } =
useCreateSecurityDashboardLink();
const getHref = useCallback(
(id: string | undefined) => (id ? getSecuritySolutionDashboardUrl(id) : createDashboardUrl),
[createDashboardUrl, getSecuritySolutionDashboardUrl]
);
const goToDashboard = useCallback(
(dashboardId: string | undefined) => {
track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD);
navigateTo({ url: getHref(dashboardId) });
},
[getHref, navigateTo]
);
const securityTags = useSecurityTags();
const securityTagsExist = securityTags && securityTags?.length > 0;
const initialFilter = useMemo(() => getInitialFilterString(securityTags), [securityTags]);
return (
<SecuritySolutionPageWrapper>
<Header canCreateDashboard={canCreateDashboard} />
@ -68,17 +123,26 @@ export const DashboardsLandingPage = () => {
</EuiTitle>
<EuiHorizontalRule margin="s" />
<LandingImageCards items={dashboardLinks} />
<EuiSpacer size="xxl" />
<EuiSpacer size="m" />
{canReadDashboard && (
{canReadDashboard && securityTagsExist && initialFilter ? (
<>
<EuiTitle size="xxxs">
<h2>{i18n.DASHBOARDS_PAGE_SECTION_CUSTOM}</h2>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<EuiSpacer size="m" />
<DashboardsTable />
<DashboardListingTable
disableCreateDashboardButton={loadingCreateDashboardUrl}
getDashboardUrl={getSecuritySolutionDashboardUrl}
goToDashboard={goToDashboard}
initialFilter={initialFilter}
urlStateEnabled={false}
>
<EuiTitle size="xxxs">
<h2>{i18n.DASHBOARDS_PAGE_SECTION_CUSTOM}</h2>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<EuiSpacer size="m" />
</DashboardListingTable>
</>
) : (
<EuiEmptyPrompt icon={<EuiLoadingSpinner size="l" />} />
)}
<SpyRoute pageName={SecurityPageName.dashboards} />

View file

@ -11,6 +11,7 @@ import type {
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { TagAttributes } from '@kbn/saved-objects-tagging-plugin/common';
import { getRandomColor } from '../../../../common/utils/get_ramdom_color';
interface CreateTagParams {
savedObjectsClient: SavedObjectsClientContract;
@ -20,13 +21,6 @@ interface CreateTagParams {
references?: SavedObjectReference[];
}
/**
* Returns the hex representation of a random color (e.g `#F1B7E2`)
*/
const getRandomColor = (): string => {
return `#${String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0')}`;
};
export const createTag = async ({
savedObjectsClient,
tagName,