diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 979e618fb505..a97f948a9d67 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -126,6 +126,7 @@ export { ChromeNavGroup, NavGroupType, NavGroupStatus, + WorkspaceAttributeWithPermission, } from '../types'; export { diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index 71b1445e95aa..cd53f2b4123a 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -52,6 +52,7 @@ export class SimpleSavedObject { public error: SavedObjectType['error']; public references: SavedObjectType['references']; public updated_at: SavedObjectType['updated_at']; + public workspaces: SavedObjectType['workspaces']; constructor( private client: SavedObjectsClientContract, @@ -64,6 +65,7 @@ export class SimpleSavedObject { references, migrationVersion, updated_at: updateAt, + workspaces, }: SavedObjectType ) { this.id = id; @@ -73,6 +75,7 @@ export class SimpleSavedObject { this._version = version; this.migrationVersion = migrationVersion; this.updated_at = updateAt; + this.workspaces = workspaces; if (error) { this.error = error; } diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index 3ead301fd7c2..0649f595b643 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -222,7 +222,7 @@ exports[`WorkspaceList should render title and table normally 1`] = ` data-test-subj="tableHeaderCell_name_0" role="columnheader" scope="col" - style="width: 25%;" + style="width: 15%;" > + + + + + +
+ +
+ Data sources +
+
+ @@ -498,7 +593,7 @@ exports[`WorkspaceList should render title and table normally 1`] = `
+
+ Owners +
+
+ +
+ +
+ Data sources +
+
+ @@ -650,7 +771,7 @@ exports[`WorkspaceList should render title and table normally 1`] = `
+
+ Owners +
+
+ +
+ +
+ Data sources +
+
+ 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 54123acceb9f..bec37c5bb160 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import moment from 'moment'; import { of } from 'rxjs'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; import { I18nProvider } from '@osd/i18n/react'; import { coreMock } from '../../../../../core/public/mocks'; import { navigateToWorkspaceDetail } from '../utils/workspace'; @@ -36,6 +36,30 @@ jest.mock('../delete_workspace_modal', () => ({ ), })); +jest.mock('../../utils', () => { + const original = jest.requireActual('../../utils'); + return { + ...original, + getDataSourcesList: jest.fn().mockResolvedValue(() => [ + { + id: 'ds_id1', + title: 'ds_title1', + workspaces: 'id1', + }, + { + id: 'ds_id2', + title: 'ds_title2', + workspaces: 'id1', + }, + { + id: 'ds_id3', + title: 'ds_title3', + workspaces: 'id1', + }, + ]), + }; +}); + function getWrapWorkspaceListInContext( workspaceList = [ { @@ -45,6 +69,11 @@ function getWrapWorkspaceListInContext( description: 'should be able to see the description tooltip when hovering over the description', lastUpdatedTime: '1999-08-06T02:00:00.00Z', + permissions: { + write: { + users: ['admin', 'nonadmin'], + }, + }, }, { id: 'id2', @@ -104,6 +133,10 @@ function getWrapWorkspaceListInContext( } describe('WorkspaceList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render title and table normally', () => { const { getByText, getByRole, container } = render(getWrapWorkspaceListInContext()); expect( @@ -268,4 +301,26 @@ describe('WorkspaceList', () => { const { queryByText } = render(getWrapWorkspaceListInContext([], false)); expect(queryByText('Create workspace')).toBeNull(); }); + + it('should render data source badge when more than two data sources', async () => { + const { getByTestId } = render(getWrapWorkspaceListInContext()); + expect(navigateToWorkspaceDetail).not.toHaveBeenCalled(); + await waitFor(() => { + const badge = getByTestId('workspaceList-more-dataSources-badge'); + expect(badge).toBeInTheDocument(); + fireEvent.click(badge); + }); + expect(navigateToWorkspaceDetail).toHaveBeenCalledTimes(1); + }); + + it('should render owners badge when more than one owners', async () => { + const { getByTestId } = render(getWrapWorkspaceListInContext()); + expect(navigateToWorkspaceDetail).not.toHaveBeenCalled(); + await waitFor(() => { + const badge = getByTestId('workspaceList-more-collaborators-badge'); + expect(badge).toBeInTheDocument(); + fireEvent.click(badge); + }); + expect(navigateToWorkspaceDetail).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 91fa716890bc..ab38ce7e6a88 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import moment from 'moment'; import { EuiPage, @@ -19,27 +19,36 @@ import { EuiButtonEmpty, EuiButton, EuiEmptyPrompt, + EuiBadge, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, of } from 'rxjs'; import { i18n } from '@osd/i18n'; -import { DEFAULT_NAV_GROUPS, WorkspaceAttribute } from '../../../../../core/public'; +import { + DEFAULT_NAV_GROUPS, + WorkspaceAttribute, + WorkspaceAttributeWithPermission, +} from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { navigateToWorkspaceDetail } from '../utils/workspace'; +import { DetailTab } from '../workspace_form/constants'; import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { getFirstUseCaseOfFeatureConfigs, getDataSourcesList } from '../../utils'; import { WorkspaceUseCase } from '../../types'; import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; +import { WorkspacePermissionMode } from '../../../common/constants'; +import { DataSourceAttributesWithWorkspaces } from '../../types'; export interface WorkspaceListProps { registeredUseCases$: BehaviorSubject; } -interface WorkspaceAttributeWithUseCaseID extends WorkspaceAttribute { +interface WorkspaceAttributeWithUseCaseIDAndDataSources extends WorkspaceAttribute { useCase?: string; + dataSources?: string[]; } export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { @@ -50,6 +59,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { http, navigationUI: { HeaderControl }, uiSettings, + savedObjects, }, } = useOpenSearchDashboards<{ navigationUI: NavigationPublicPluginStart['ui']; @@ -67,6 +77,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { }); const [deletedWorkspaces, setDeletedWorkspaces] = useState([]); const [selection, setSelection] = useState([]); + const [allDataSources, setAllDataSources] = useState([]); const dateFormat = uiSettings?.get('dateFormat'); @@ -87,14 +98,28 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { [registeredUseCases] ); - const newWorkspaceList: WorkspaceAttributeWithUseCaseID[] = useMemo(() => { + useEffect(() => { + if (savedObjects) { + getDataSourcesList(savedObjects.client, ['*']).then((data) => { + setAllDataSources(data); + }); + } + }, [savedObjects]); + + const newWorkspaceList: WorkspaceAttributeWithUseCaseIDAndDataSources[] = useMemo(() => { return workspaceList.map( - (workspace): WorkspaceAttributeWithUseCaseID => ({ - ...workspace, - useCase: extractUseCaseFromFeatures(workspace.features ?? []), - }) + (workspace): WorkspaceAttributeWithUseCaseIDAndDataSources => { + const associatedDataSourcesTitles = allDataSources + .filter((ds) => ds.workspaces && ds.workspaces.includes(workspace.id)) + .map((ds) => ds.title as string); + return { + ...workspace, + useCase: extractUseCaseFromFeatures(workspace.features ?? []), + dataSources: associatedDataSourcesTitles, + }; + } ); - }, [workspaceList, extractUseCaseFromFeatures]); + }, [workspaceList, extractUseCaseFromFeatures, allDataSources]); const workspaceCreateUrl = useMemo(() => { if (!application) { return ''; @@ -166,14 +191,43 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { }; const handleSwitchWorkspace = useCallback( - (id: string) => { + (id: string, tab?: DetailTab) => { if (application && http) { - navigateToWorkspaceDetail({ application, http }, id); + navigateToWorkspaceDetail({ application, http }, id, tab); } }, [application, http] ); + const renderDataWithMoreBadge = ( + data: string[], + maxDisplayedAmount: number, + workspaceId: string, + tab: DetailTab + ) => { + const amount = data.length; + const mostDisplayedTitles = data.slice(0, maxDisplayedAmount).join(','); + return amount <= maxDisplayedAmount ? ( + mostDisplayedTitles + ) : ( + <> + {mostDisplayedTitles}  + handleSwitchWorkspace(workspaceId, tab)} + iconOnClick={() => handleSwitchWorkspace(workspaceId, tab)} + iconOnClickAriaLabel="Open workspace detail" + onClickAriaLabel="Open workspace detail" + data-test-subj={`workspaceList-more-${tab}-badge`} + > + + {amount - maxDisplayedAmount} more + + + ); + }; + const renderToolsLeft = () => { if (selection.length === 0) { return; @@ -245,9 +299,9 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { { field: 'name', name: 'Name', - width: '25%', + width: '15%', sortable: true, - render: (name: string, item: WorkspaceAttribute) => ( + render: (name: string, item: WorkspaceAttributeWithPermission) => ( handleSwitchWorkspace(item.id)}> {name} @@ -259,13 +313,13 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { { field: 'useCase', name: 'Use case', - width: '20%', + width: '15%', }, { field: 'description', name: 'Description', - width: '20%', + width: '15%', render: (description: string) => ( { ), }, + { + field: 'permissions', + name: 'Owners', + width: '15%', + render: ( + permissions: WorkspaceAttributeWithPermission['permissions'], + item: WorkspaceAttributeWithPermission + ) => { + const owners = permissions?.[WorkspacePermissionMode.Write]?.users ?? []; + return renderDataWithMoreBadge(owners, 1, item.id, DetailTab.Collaborators); + }, + }, { field: 'lastUpdatedTime', name: 'Last updated', - width: '25%', + width: '15%', truncateText: false, render: (lastUpdatedTime: string) => { return moment(lastUpdatedTime).format(dateFormat); }, }, - + { + field: 'dataSources', + width: '15%', + name: 'Data sources', + render: (dataSources: string[], item: WorkspaceAttributeWithPermission) => { + return renderDataWithMoreBadge(dataSources, 2, item.id, DetailTab.DataSources); + }, + }, { name: 'Actions', field: '', diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 8e5076a6cca7..6d47262bbdd9 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -7,6 +7,7 @@ import { CoreStart } from '../../../core/public'; import { WorkspaceClient } from './workspace_client'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; +import { DataSourceAttributes } from '../../../plugins/data_source/common/data_sources'; export type Services = CoreStart & { workspaceClient: WorkspaceClient; @@ -27,3 +28,7 @@ export interface WorkspaceUseCase { systematic?: boolean; order?: number; } + +export interface DataSourceAttributesWithWorkspaces extends Omit { + workspaces?: string[]; +} diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 03aa0e208222..dfca65fcaf98 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -398,6 +398,7 @@ describe('workspace utils: getDataSourcesList', () => { auth: 'mock_value', description: 'description1', dataSourceEngineType: 'dataSourceEngineType1', + workspaces: [], }, ]); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index ff3983e415d4..543a79fbc4a3 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -201,13 +201,16 @@ export const filterWorkspaceConfigurableApps = (applications: PublicAppInfo[]) = return visibleApplications; }; -export const getDataSourcesList = (client: SavedObjectsStart['client'], workspaces: string[]) => { +export const getDataSourcesList = ( + client: SavedObjectsStart['client'], + targetWorkspaces: string[] +) => { return client .find({ type: 'data-source', fields: ['id', 'title', 'auth', 'description', 'dataSourceEngineType'], perPage: 10000, - workspaces, + workspaces: targetWorkspaces, }) .then((response) => { const objects = response?.savedObjects; @@ -215,6 +218,7 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac return objects.map((source) => { const id = source.id; const title = source.get('title'); + const workspaces = source.workspaces ?? []; const auth = source.get('auth'); const description = source.get('description'); const dataSourceEngineType = source.get('dataSourceEngineType'); @@ -224,6 +228,7 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac auth, description, dataSourceEngineType, + workspaces, }; }); } else {