@@ -130,6 +192,29 @@ exports[`WorkspaceList should render title and table normally 1`] = `
/>
+
|
@@ -240,8 +321,32 @@ exports[`WorkspaceList should render title and table normally 1`] = `
+
+
+ |
|
- id1
+ Analytics (All)
|
|
- Analytics (All)
+ Aug 5, 1999 @ 22:00:00.000
|
-
-
-
- Edit
-
-
-
-
-
- Delete
-
-
+
+
+
+
+
+
+
|
+
+
+ |
|
- id2
+ Observability
|
|
+ >
+ Aug 5, 1999 @ 20:00:00.000
+
|
-
-
-
- Edit
-
-
-
-
-
- Delete
-
-
+
+
+
+
+
+
+
|
+
+
+ |
|
- id3
-
+ />
|
|
- Observability
+ Aug 5, 1999 @ 21:00:00.000
|
-
-
-
- Edit
-
-
-
-
-
- Delete
-
-
+
+
+
+
+
+
+
|
diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx
index 2284008d9d36..f45f5feaf860 100644
--- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx
+++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx
@@ -4,6 +4,7 @@
*/
import React from 'react';
+import moment from 'moment';
import { BehaviorSubject, of } from 'rxjs';
import { render, fireEvent, screen } from '@testing-library/react';
import { I18nProvider } from '@osd/i18n/react';
@@ -15,6 +16,18 @@ import { WorkspaceList } from './index';
jest.mock('../utils/workspace');
+const mockNavigatorWrite = jest.fn();
+
+jest.mock('@elastic/eui', () => {
+ const original = jest.requireActual('@elastic/eui');
+ return {
+ ...original,
+ copyToClipboard: jest.fn().mockImplementation((id) => {
+ mockNavigatorWrite(id);
+ }),
+ };
+});
+
jest.mock('../delete_workspace_modal', () => ({
DeleteWorkspaceModal: ({ onClose }: { onClose: () => void }) => (
@@ -25,9 +38,29 @@ jest.mock('../delete_workspace_modal', () => ({
function getWrapWorkspaceListInContext(
workspaceList = [
- { id: 'id1', name: 'name1', features: ['use-case-all'] },
- { id: 'id2', name: 'name2' },
- { id: 'id3', name: 'name3', features: ['use-case-observability'] },
+ {
+ id: 'id1',
+ name: 'name1',
+ features: ['use-case-all'],
+ description:
+ 'should be able to see the description tooltip when hovering over the description',
+ lastUpdatedTime: '1999-08-06T02:00:00.00Z',
+ },
+ {
+ id: 'id2',
+ name: 'name2',
+ features: ['use-case-observability'],
+ description:
+ 'should be able to see the description tooltip when hovering over the description',
+ lastUpdatedTime: '1999-08-06T00:00:00.00Z',
+ },
+ {
+ id: 'id3',
+ name: 'name3',
+ features: ['use-case-search'],
+ description: '',
+ lastUpdatedTime: '1999-08-06T01:00:00.00Z',
+ },
],
isDashboardAdmin = true
) {
@@ -48,6 +81,14 @@ function getWrapWorkspaceListInContext(
workspaces: {
workspaceList$: of(workspaceList),
},
+ uiSettings: {
+ get: jest.fn().mockImplementation((key) => {
+ if (key === 'dateFormat') {
+ return 'MMM D, YYYY @ HH:mm:ss.SSS';
+ }
+ return null;
+ }),
+ },
navigationUI: {
HeaderControl: mockHeaderControl,
},
@@ -84,26 +125,27 @@ describe('WorkspaceList', () => {
expect(getByText('Analytics (All)')).toBeInTheDocument();
expect(getByText('Observability')).toBeInTheDocument();
});
- it('should be able to apply debounce search after input', async () => {
- const list = [
- { id: 'id1', name: 'name1' },
- { id: 'id2', name: 'name2' },
- { id: 'id3', name: 'name3' },
- { id: 'id4', name: 'name4' },
- { id: 'id5', name: 'name5' },
- { id: 'id6', name: 'name6' },
- ];
- const { getByText, getByRole, queryByText } = render(getWrapWorkspaceListInContext(list));
- expect(getByText('name1')).toBeInTheDocument();
- expect(queryByText('name6')).not.toBeInTheDocument();
+
+ it('should be able to search and re-render the list', async () => {
+ const { getByText, getByRole, queryByText } = render(getWrapWorkspaceListInContext());
const input = getByRole('searchbox');
fireEvent.change(input, {
- target: { value: 'nam' },
+ target: { value: 'name2' },
});
+ expect(getByText('name2')).toBeInTheDocument();
+ expect(queryByText('name1')).not.toBeInTheDocument();
+ expect(queryByText('name3')).not.toBeInTheDocument();
+ });
+
+ it('should be able to apply debounce search after input', async () => {
+ const { getByText, getByRole, queryByText } = render(getWrapWorkspaceListInContext());
+ const input = getByRole('searchbox');
fireEvent.change(input, {
- target: { value: 'name6' },
+ target: { value: 'name2' },
});
- expect(queryByText('name6')).not.toBeInTheDocument();
+ expect(getByText('name2')).toBeInTheDocument();
+ expect(queryByText('name1')).not.toBeInTheDocument();
+ expect(queryByText('name3')).not.toBeInTheDocument();
});
it('should be able to switch workspace after clicking name', async () => {
@@ -113,16 +155,51 @@ describe('WorkspaceList', () => {
expect(navigateToWorkspaceDetail).toBeCalled();
});
+ it('should be able to perform the time format transformation', async () => {
+ const { getByText } = render(getWrapWorkspaceListInContext());
+ expect(
+ getByText(moment('1999-08-06T00:00:00.00Z').format('MMM D, YYYY @ HH:mm:ss.SSS'))
+ ).toBeInTheDocument();
+ expect(
+ getByText(moment('1999-08-06T01:00:00.00Z').format('MMM D, YYYY @ HH:mm:ss.SSS'))
+ ).toBeInTheDocument();
+ expect(
+ getByText(moment('1999-08-06T02:00:00.00Z').format('MMM D, YYYY @ HH:mm:ss.SSS'))
+ ).toBeInTheDocument();
+ });
+
+ it('should be able to see the 3 operations: copy, update, delete after click in the meatballs button', async () => {
+ const { getAllByTestId, getByText } = render(getWrapWorkspaceListInContext());
+ const operationIcons = getAllByTestId('euiCollapsedItemActionsButton')[0];
+ fireEvent.click(operationIcons);
+ expect(getByText('Copy ID')).toBeInTheDocument();
+ expect(getByText('Edit')).toBeInTheDocument();
+ expect(getByText('Delete')).toBeInTheDocument();
+ });
+
+ it('should be able to copy workspace ID after clicking copy button', async () => {
+ const { getByText, getAllByTestId } = render(getWrapWorkspaceListInContext());
+ const operationIcons = getAllByTestId('euiCollapsedItemActionsButton')[0];
+ fireEvent.click(operationIcons);
+ const copyIcon = getByText('Copy ID');
+ fireEvent.click(copyIcon);
+ expect(mockNavigatorWrite).toHaveBeenCalledWith('id1');
+ });
+
it('should be able to update workspace after clicking name', async () => {
- const { getAllByTestId } = render(getWrapWorkspaceListInContext());
- const editIcon = getAllByTestId('workspace-list-edit-icon')[0];
+ const { getByText, getAllByTestId } = render(getWrapWorkspaceListInContext());
+ const operationIcons = getAllByTestId('euiCollapsedItemActionsButton')[0];
+ fireEvent.click(operationIcons);
+ const editIcon = getByText('Edit');
fireEvent.click(editIcon);
expect(navigateToWorkspaceDetail).toBeCalled();
});
it('should be able to call delete modal after clicking delete button', async () => {
- const { getAllByTestId } = render(getWrapWorkspaceListInContext());
- const deleteIcon = getAllByTestId('workspace-list-delete-icon')[0];
+ const { getByText, getAllByTestId } = render(getWrapWorkspaceListInContext());
+ const operationIcons = getAllByTestId('euiCollapsedItemActionsButton')[0];
+ fireEvent.click(operationIcons);
+ const deleteIcon = getByText('Delete');
fireEvent.click(deleteIcon);
expect(screen.queryByLabelText('mock delete workspace modal')).toBeInTheDocument();
const modalCancelButton = screen.getByLabelText('mock delete workspace modal button');
@@ -132,12 +209,48 @@ describe('WorkspaceList', () => {
it('should be able to pagination when clicking pagination button', async () => {
const list = [
- { id: 'id1', name: 'name1' },
- { id: 'id2', name: 'name2' },
- { id: 'id3', name: 'name3' },
- { id: 'id4', name: 'name4' },
- { id: 'id5', name: 'name5' },
- { id: 'id6', name: 'name6' },
+ {
+ id: 'id1',
+ name: 'name1',
+ features: ['use-case-all'],
+ description: '',
+ lastUpdatedTime: '2024-08-06T00:00:00.00Z',
+ },
+ {
+ id: 'id2',
+ name: 'name2',
+ features: ['use-case-observability'],
+ description: '',
+ lastUpdatedTime: '2024-08-06T00:00:00.00Z',
+ },
+ {
+ id: 'id3',
+ name: 'name3',
+ features: ['use-case-search'],
+ description: '',
+ lastUpdatedTime: '2024-08-06T00:00:00.00Z',
+ },
+ {
+ id: 'id4',
+ name: 'name4',
+ features: ['use-case-all'],
+ description: '',
+ lastUpdatedTime: '2024-08-05T00:00:00.00Z',
+ },
+ {
+ id: 'id5',
+ name: 'name5',
+ features: ['use-case-observability'],
+ description: '',
+ lastUpdatedTime: '2024-08-06T00:00:00.00Z',
+ },
+ {
+ id: 'id6',
+ name: 'name6',
+ features: ['use-case-search'],
+ description: '',
+ lastUpdatedTime: '2024-08-06T00:00:00.00Z',
+ },
];
const { getByTestId, getByText, queryByText } = render(getWrapWorkspaceListInContext(list));
expect(getByText('name1')).toBeInTheDocument();
@@ -149,14 +262,12 @@ describe('WorkspaceList', () => {
});
it('should display create workspace button for dashboard admin', async () => {
- const { getByText } = render(getWrapWorkspaceListInContext([], true));
-
- expect(getByText('Create workspace')).toBeInTheDocument();
+ const { getAllByText } = render(getWrapWorkspaceListInContext([], true));
+ expect(getAllByText('Create workspace')[0]).toBeInTheDocument();
});
it('should hide create workspace button for non dashboard admin', async () => {
const { queryByText } = render(getWrapWorkspaceListInContext([], false));
-
expect(queryByText('Create workspace')).toBeNull();
});
});
diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx
index 931862b919a7..91fa716890bc 100644
--- a/src/plugins/workspace/public/components/workspace_list/index.tsx
+++ b/src/plugins/workspace/public/components/workspace_list/index.tsx
@@ -4,19 +4,26 @@
*/
import React, { useState, useMemo, useCallback } from 'react';
+import moment from 'moment';
import {
EuiPage,
EuiPageContent,
EuiLink,
EuiSmallButton,
EuiInMemoryTable,
+ EuiToolTip,
+ EuiText,
EuiSearchBarProps,
+ copyToClipboard,
+ EuiTableSelectionType,
+ EuiButtonEmpty,
+ EuiButton,
+ EuiEmptyPrompt,
} from '@elastic/eui';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, of } from 'rxjs';
import { i18n } from '@osd/i18n';
-import { debounce, DEFAULT_NAV_GROUPS } from '../../../../../core/public';
-import { WorkspaceAttribute } from '../../../../../core/public';
+import { DEFAULT_NAV_GROUPS, WorkspaceAttribute } from '../../../../../core/public';
import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public';
import { navigateToWorkspaceDetail } from '../utils/workspace';
@@ -31,6 +38,10 @@ export interface WorkspaceListProps {
registeredUseCases$: BehaviorSubject;
}
+interface WorkspaceAttributeWithUseCaseID extends WorkspaceAttribute {
+ useCase?: string;
+}
+
export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => {
const {
services: {
@@ -38,47 +49,52 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => {
application,
http,
navigationUI: { HeaderControl },
+ uiSettings,
},
} = useOpenSearchDashboards<{
navigationUI: NavigationPublicPluginStart['ui'];
}>();
const registeredUseCases = useObservable(registeredUseCases$);
const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin;
-
const initialSortField = 'name';
const initialSortDirection = 'asc';
const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []);
- const [queryInput, setQueryInput] = useState('');
+
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 5,
pageSizeOptions: [5, 10, 20],
});
- const [deletedWorkspace, setDeletedWorkspace] = useState(null);
+ const [deletedWorkspaces, setDeletedWorkspaces] = useState([]);
+ const [selection, setSelection] = useState([]);
- const handleSwitchWorkspace = useCallback(
- (id: string) => {
- if (application && http) {
- navigateToWorkspaceDetail({ application, http }, id);
+ const dateFormat = uiSettings?.get('dateFormat');
+
+ const extractUseCaseFromFeatures = useCallback(
+ (features: string[]) => {
+ if (!features || features.length === 0) {
+ return '';
+ }
+ const useCaseId = getFirstUseCaseOfFeatureConfigs(features);
+ const usecase =
+ useCaseId === DEFAULT_NAV_GROUPS.all.id
+ ? DEFAULT_NAV_GROUPS.all
+ : registeredUseCases?.find(({ id }) => id === useCaseId);
+ if (usecase) {
+ return usecase.title;
}
},
- [application, http]
+ [registeredUseCases]
);
- const searchResult = useMemo(() => {
- if (queryInput) {
- const normalizedQuery = queryInput.toLowerCase();
- const result = workspaceList.filter((item) => {
- return (
- item.id.toLowerCase().indexOf(normalizedQuery) > -1 ||
- item.name.toLowerCase().indexOf(normalizedQuery) > -1
- );
- });
- return result;
- }
- return workspaceList;
- }, [workspaceList, queryInput]);
-
+ const newWorkspaceList: WorkspaceAttributeWithUseCaseID[] = useMemo(() => {
+ return workspaceList.map(
+ (workspace): WorkspaceAttributeWithUseCaseID => ({
+ ...workspace,
+ useCase: extractUseCaseFromFeatures(workspace.features ?? []),
+ })
+ );
+ }, [workspaceList, extractUseCaseFromFeatures]);
const workspaceCreateUrl = useMemo(() => {
if (!application) {
return '';
@@ -92,6 +108,38 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => {
return appUrl;
}, [application]);
+ const emptyStateMessage = useMemo(() => {
+ return (
+
+ {i18n.translate('workspace.workspaceList.emptyState.title', {
+ defaultMessage: 'No workspace available',
+ })}
+
+ }
+ titleSize="s"
+ body={i18n.translate('workspace.workspaceList.emptyState.body', {
+ defaultMessage: 'There are no workspace to display. Create workspace to get started.',
+ })}
+ actions={
+ isDashboardAdmin && (
+
+ {i18n.translate('workspace.workspaceList.buttons.createWorkspace', {
+ defaultMessage: 'Create workspace',
+ })}
+
+ )
+ }
+ />
+ );
+ }, [isDashboardAdmin, workspaceCreateUrl]);
+
const renderCreateWorkspaceButton = () => {
const button = (
{
);
};
+ const handleCopyId = (id: string) => {
+ copyToClipboard(id);
+ };
+
+ const handleSwitchWorkspace = useCallback(
+ (id: string) => {
+ if (application && http) {
+ navigateToWorkspaceDetail({ application, http }, id);
+ }
+ },
+ [application, http]
+ );
+
+ const renderToolsLeft = () => {
+ if (selection.length === 0) {
+ return;
+ }
+
+ const onClick = () => {
+ const deleteWorkspacesByIds = (workSpaces: WorkspaceAttribute[], ids: string[]) => {
+ const needToBeDeletedWorkspaceList: WorkspaceAttribute[] = [];
+ ids.forEach((id) => {
+ const index = workSpaces.findIndex((workSpace) => workSpace.id === id);
+ if (index >= 0) {
+ needToBeDeletedWorkspaceList.push(workSpaces[index]);
+ }
+ });
+ return needToBeDeletedWorkspaceList;
+ };
+
+ setDeletedWorkspaces(
+ deleteWorkspacesByIds(
+ newWorkspaceList,
+ selection.map((item) => item.id)
+ )
+ );
+
+ setSelection([]);
+ };
+
+ return (
+ <>
+
+ Delete {selection.length} Workspace
+
+ {deletedWorkspaces && deletedWorkspaces.length > 0 && (
+ setDeletedWorkspaces([])}
+ />
+ )}
+ >
+ );
+ };
+
+ const selectionValue: EuiTableSelectionType = {
+ onSelectionChange: (deletedSelection) => setSelection(deletedSelection),
+ };
+
+ const search: EuiSearchBarProps = {
+ box: {
+ incremental: true,
+ },
+ filters: [
+ {
+ type: 'field_value_selection',
+ field: 'useCase',
+ name: 'Use Case',
+ multiSelect: false,
+ options: Array.from(
+ new Set(newWorkspaceList.map(({ useCase }) => useCase).filter(Boolean))
+ ).map((useCase) => ({
+ value: useCase!,
+ name: useCase!,
+ })),
+ },
+ ],
+ toolsLeft: renderToolsLeft(),
+ };
+
const columns = [
{
field: 'name',
name: 'Name',
+ width: '25%',
sortable: true,
render: (name: string, item: WorkspaceAttribute) => (
- handleSwitchWorkspace(item.id)}>{name}
+ handleSwitchWorkspace(item.id)}>
+ {name}
+
),
},
+
{
- field: 'id',
- name: 'ID',
- sortable: true,
+ field: 'useCase',
+ name: 'Use case',
+ width: '20%',
},
+
{
field: 'description',
name: 'Description',
- truncateText: true,
+ width: '20%',
+ render: (description: string) => (
+
+ {/* Here I need to set width mannuly as the tooltip will ineffect the property : truncateText ', */}
+
+ {description}
+
+
+ ),
},
{
- field: 'features',
- name: 'Use case',
- isExpander: true,
- hasActions: true,
- render: (features: string[]) => {
- if (!features || features.length === 0) {
- return '';
- }
- const useCaseId = getFirstUseCaseOfFeatureConfigs(features);
- const useCase =
- useCaseId === DEFAULT_NAV_GROUPS.all.id
- ? DEFAULT_NAV_GROUPS.all
- : registeredUseCases?.find(({ id }) => id === useCaseId);
- if (useCase) {
- return useCase.title;
- }
+ field: 'lastUpdatedTime',
+ name: 'Last updated',
+ width: '25%',
+ truncateText: false,
+ render: (lastUpdatedTime: string) => {
+ return moment(lastUpdatedTime).format(dateFormat);
},
},
+
{
name: 'Actions',
field: '',
actions: [
+ {
+ name: 'Copy ID',
+ type: 'button',
+ description: 'Copy id',
+ 'data-test-subj': 'workspace-list-copy-id-icon',
+ render: ({ id }: WorkspaceAttribute) => {
+ return (
+ handleCopyId(id)}
+ size="xs"
+ iconType="copy"
+ color="text"
+ >
+ Copy ID
+
+ );
+ },
+ },
{
name: 'Edit',
- icon: 'pencil',
type: 'icon',
+ icon: 'edit',
+ color: 'danger',
description: 'Edit workspace',
- onClick: ({ id }: WorkspaceAttribute) => handleSwitchWorkspace(id),
'data-test-subj': 'workspace-list-edit-icon',
+ onClick: ({ id }: WorkspaceAttribute) => handleSwitchWorkspace(id),
+ render: ({ id }: WorkspaceAttribute) => {
+ return (
+ handleSwitchWorkspace(id)}
+ iconType="pencil"
+ size="xs"
+ color="text"
+ >
+ Edit
+
+ );
+ },
},
{
name: 'Delete',
- icon: 'trash',
- type: 'icon',
+ type: 'button',
description: 'Delete workspace',
- onClick: (item: WorkspaceAttribute) => setDeletedWorkspace(item),
'data-test-subj': 'workspace-list-delete-icon',
+ render: (item: WorkspaceAttribute) => {
+ return (
+ {
+ setDeletedWorkspaces([item]);
+ }}
+ size="xs"
+ iconType="trash"
+ color="danger"
+ >
+ Delete
+
+ );
+ },
},
],
},
];
- const debouncedSetQueryInput = useMemo(() => {
- return debounce(setQueryInput, 300);
- }, [setQueryInput]);
-
- const handleSearchInput: EuiSearchBarProps['onChange'] = useCallback(
- ({ query }) => {
- debouncedSetQueryInput(query?.text ?? '');
- },
- [debouncedSetQueryInput]
- );
-
- const search: EuiSearchBarProps = {
- onChange: handleSearchInput,
- box: {
- incremental: true,
- },
- };
-
return (
{
hasShadow={false}
>
setPagination((prev) => {
return { ...prev, pageIndex: index, pageSize: size };
@@ -233,12 +395,14 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => {
}}
isSelectable={true}
search={search}
+ selection={selectionValue}
/>
- {deletedWorkspace && (
+
+ {deletedWorkspaces.length > 0 && (
setDeletedWorkspace(null)}
+ selectedWorkspaces={deletedWorkspaces}
+ onClose={() => setDeletedWorkspaces([])}
/>
)}