From 2e400e4850c084b43c575615aef3bc40fbdedd11 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Thu, 18 Jul 2024 11:13:15 +0800 Subject: [PATCH] workspace list card on home Signed-off-by: Hailong Cui --- src/core/public/index.ts | 2 + src/core/types/workspace.ts | 1 + src/plugins/home/public/index.ts | 2 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 4 +- .../workspace_list_card.test.tsx.snap | 121 +++++++++ .../public/components/service_card/index.ts | 6 + .../service_card/workspace_list_card.test.tsx | 66 +++++ .../service_card/workspace_list_card.tsx | 239 ++++++++++++++++++ src/plugins/workspace/public/plugin.ts | 37 ++- src/plugins/workspace/public/utils.ts | 2 +- .../workspace/server/workspace_client.ts | 1 + 12 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/service_card/index.ts create mode 100644 src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx create mode 100644 src/plugins/workspace/public/components/service_card/workspace_list_card.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 8c387361a9ca..002e1df37846 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -76,6 +76,7 @@ import { PersistedLog, NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, + PersistedLog, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; import { HttpSetup, HttpStart } from './http'; @@ -379,6 +380,7 @@ export { PersistedLog, NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, + PersistedLog, }; export { __osdBootstrap__ } from './osd_bootstrap'; diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index d0a0d47b2216..c00d3576d567 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -14,6 +14,7 @@ export interface WorkspaceAttribute { icon?: string; reserved?: boolean; uiSettings?: Record; + lastUpdatedTime?: string; } export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index 58ad10cdf04b..d252a31a0977 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -53,3 +53,5 @@ import { HomePublicPlugin } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => new HomePublicPlugin(initializerContext); + +export { HOME_PAGE_ID, HOME_CONTENT_AREAS } from '../common/constants'; diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 9fef78152d8d..6cb0a00ed72a 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -185,3 +185,5 @@ export const WORKSPACE_USE_CASES = Object.freeze({ export const MAX_WORKSPACE_PICKER_NUM = 3; export const RECENT_WORKSPACES_KEY = 'recentWorkspaces'; export const CURRENT_USER_PLACEHOLDER = '%me%'; +export const MAX_WORKSPACE_NAME_LENGTH = 25; +export const RECENT_WORKSPACES_KEY = 'recentWorkspaces'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 2e9377b3bda9..5838d070264f 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,6 +7,6 @@ "savedObjects", "opensearchDashboardsReact" ], - "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement"], - "requiredBundles": ["opensearchDashboardsReact"] + "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement","contentManagement"], + "requiredBundles": ["opensearchDashboardsReact","home"] } diff --git a/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap new file mode 100644 index 000000000000..ca48a73e7a40 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`workspace list card render normally should show workspace list card correctly 1`] = ` +
+
+
+
+

+ Workspaces +

+
+
+ + + +
+
+
+
+ +
+ + +
+
+
+
+
+
+
    +
    + +
    +

    + No Workspaces found +

    + +
    +
    + Workspaces you have recently viewed will appear here. +
    + +
    +
+ +
+
+`; diff --git a/src/plugins/workspace/public/components/service_card/index.ts b/src/plugins/workspace/public/components/service_card/index.ts new file mode 100644 index 000000000000..9bfc561f2561 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceListCard } from './workspace_list_card'; diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx new file mode 100644 index 000000000000..24d45d42e725 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceListCard } from './workspace_list_card'; +import { recentWorkspaceManager } from '../../recent_workspace_manager'; + +describe('workspace list card render normally', () => { + const coreStart = coreMock.createStart(); + + beforeAll(() => { + const workspaceList = [ + { + id: 'ws-1', + name: 'foo', + lastUpdatedTime: new Date().toISOString(), + }, + { + id: 'ws-2', + name: 'bar', + lastUpdatedTime: new Date().toISOString(), + }, + ]; + coreStart.workspaces.workspaceList$.next(workspaceList); + }); + + it('should show workspace list card correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should show empty state if no recently viewed workspace', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + // empty statue for recently viewed + expect(getByText('Workspaces you have recently viewed will appear here.')).toBeInTheDocument(); + }); + + it('should show default filter as recently viewed', () => { + recentWorkspaceManager.addRecentWorkspace('foo'); + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + waitFor(() => { + expect(getByText('foo')).toBeInTheDocument(); + }); + }); + + it('should show updated filter correctly', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + const filterSelector = getByTestId('workspace_filter'); + fireEvent.change(filterSelector, { target: { value: 'updated' } }); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently updated'); + + // workspace list + expect(getByText('foo')).toBeInTheDocument(); + expect(getByText('bar')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx new file mode 100644 index 000000000000..caa918b9f4a1 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx @@ -0,0 +1,239 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from 'react'; +import { + EuiPanel, + EuiLink, + EuiDescriptionList, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSelect, + EuiButtonIcon, + EuiSpacer, + EuiListGroup, + EuiText, + EuiTitle, + EuiToolTip, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { Subscription } from 'rxjs'; +import moment from 'moment'; +import _ from 'lodash'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { navigateToWorkspaceDetail } from '../utils/workspace'; + +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; +import { WorkspaceEntry, recentWorkspaceManager } from '../../recent_workspace_manager'; + +const WORKSPACE_LIST_CARD_DESCRIPTIOIN = i18n.translate('workspace.list.card.descriptionh', { + defaultMessage: + 'Workspaces are dedicated environments for organizing and collaborating on your data, dashboards, and analytics workflows. Each Workspace acts as a self-contained space with its own set of saved objects and access controls.', +}); + +const MAX_ITEM_IN_LIST = 5; + +export interface WorkspaceListCardProps { + core: CoreStart; +} + +export interface WorkspaceListItem { + id: string; + name: string; + time?: string | number; +} + +export interface WorkspaceListCardState { + availiableWorkspaces: WorkspaceObject[]; + filter: string; + workspaceList: WorkspaceListItem[]; + recentWorkspaces: WorkspaceEntry[]; +} + +export class WorkspaceListCard extends Component { + private workspaceSub?: Subscription; + constructor(props: WorkspaceListCardProps) { + super(props); + this.state = { + availiableWorkspaces: [], + recentWorkspaces: [], + workspaceList: [], + filter: 'viewed', + }; + } + + componentDidMount() { + this.setState({ + recentWorkspaces: recentWorkspaceManager.getRecentWorkspaces() || [], + }); + this.workspaceSub = this.props.core.workspaces.workspaceList$.subscribe((list) => { + this.setState({ + availiableWorkspaces: list || [], + }); + }); + this.loadWorkspaceListItems(); + } + + componentDidUpdate( + prevProps: Readonly, + prevState: Readonly + ): void { + if ( + !_.isEqual(prevState.filter, this.state.filter) || + !_.isEqual(prevState.availiableWorkspaces, this.state.availiableWorkspaces) || + !_.isEqual(prevState.recentWorkspaces, this.state.recentWorkspaces) + ) { + this.loadWorkspaceListItems(); + } + } + + private loadWorkspaceListItems() { + if (this.state.filter === 'viewed') { + this.setState({ + workspaceList: _.orderBy(this.state.recentWorkspaces, ['timestamp'], ['desc']) + .filter((ws) => this.state.availiableWorkspaces.some((a) => a.id === ws.id)) + .slice(0, MAX_ITEM_IN_LIST) + .map((item) => ({ + id: item.id, + name: this.state.availiableWorkspaces.find((ws) => ws.id === item.id)?.name!, + time: item.timestamp, + })), + }); + } else if (this.state.filter === 'updated') { + this.setState({ + workspaceList: _.orderBy(this.state.availiableWorkspaces, ['lastUpdatedTime'], ['desc']) + .slice(0, MAX_ITEM_IN_LIST) + .map((ws) => ({ + id: ws.id, + name: ws.name, + time: ws.lastUpdatedTime, + })), + }); + } + } + + componentWillUnmount() { + this.workspaceSub?.unsubscribe(); + } + + private handleSwitchWorkspace = (id: string) => { + const { application, http } = this.props.core; + if (application && http) { + navigateToWorkspaceDetail({ application, http }, id); + } + }; + + render() { + const workspaceList = this.state.workspaceList; + const { application } = this.props.core; + + const isDashboardAdmin = application.capabilities.dashboards?.isDashboardAdmin; + + return ( + + + + +

Workspaces

+
+
+ + + + + + + { + this.setState({ filter: e.target.value }); + }} + options={[ + { + value: 'viewed', + text: i18n.translate('workspace.list.card.filter.viewed', { + defaultMessage: 'Recently viewed', + }), + }, + { + value: 'updated', + text: i18n.translate('workspace.list.card.filter.updated', { + defaultMessage: 'Recently updated', + }), + }, + ]} + /> + + {isDashboardAdmin && ( + + + { + application.navigateToApp(WORKSPACE_CREATE_APP_ID); + }} + /> + + + )} +
+ + + + {workspaceList && workspaceList.length === 0 ? ( + No Workspaces found

} + body={i18n.translate('workspace.list.card.empty', { + values: { + filter: this.state.filter, + }, + defaultMessage: 'Workspaces you have recently {filter} will appear here.', + })} + /> + ) : ( + ({ + title: ( + { + this.handleSwitchWorkspace(workspace.id); + }} + > + {workspace.name} + + ), + description: ( + + {moment(workspace.time).fromNow()} + + ), + }))} + /> + )} +
+ + { + application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + View all + +
+ ); + } +} diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 104db7d9b91f..666f8cdde674 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -34,6 +34,7 @@ import { Services, WorkspaceUseCase } from './types'; import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { ManagementSetup } from '../../../plugins/management/public'; +import { ContentManagementPluginStart } from '../../../plugins/content_management/public'; import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; import { getWorkspaceColumn } from './components/workspace_column'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; @@ -46,6 +47,9 @@ import { import { recentWorkspaceManager } from './recent_workspace_manager'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { UseCaseService } from './services/use_case_service'; +import { recentWorkspaceManager } from './recent_workspace_manager'; +import { WorkspaceListCard } from './components/service_card'; +import { HOME_CONTENT_AREAS, HOME_PAGE_ID } from '../../home/public'; type WorkspaceAppType = ( params: AppMountParameters, @@ -59,7 +63,12 @@ interface WorkspacePluginSetupDeps { dataSourceManagement?: DataSourceManagementPluginSetup; } -export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { +interface WorkspacePluginStartDeps { + contentManagement: ContentManagementPluginStart; +} + +export class WorkspacePlugin + implements Plugin<{}, {}, WorkspacePluginSetupDeps, WorkspacePluginStartDeps> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; private breadcrumbsSubscription?: Subscription; @@ -267,6 +276,9 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> // Add workspace id to recent workspaces. recentWorkspaceManager.addRecentWorkspace(workspaceId); })(); + + // Add workspace id to recent workspaces. + recentWorkspaceManager.addRecentWorkspace(workspaceId); } } @@ -372,7 +384,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> return {}; } - public start(core: CoreStart) { + public start(core: CoreStart, { contentManagement }: WorkspacePluginStartDeps) { this.coreStart = core; this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); @@ -407,9 +419,30 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }); } + // register workspace list in home page + this.registerWorkspaceListToHome(core, contentManagement); + return {}; } + private registerWorkspaceListToHome( + core: CoreStart, + contentManagement: ContentManagementPluginStart + ) { + if (contentManagement) { + contentManagement.registerContentProvider({ + id: HOME_PAGE_ID, + getContent: () => ({ + id: 'workspace_list', + kind: 'custom', + order: 0, + render: () => React.createElement(WorkspaceListCard, { core }), + }), + getTargetArea: () => HOME_CONTENT_AREAS.SERVICE_CARDS, + }); + } + } + public stop() { this.currentWorkspaceSubscription?.unsubscribe(); this.currentWorkspaceIdSubscription?.unsubscribe(); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 589f1d8159d2..d4bc61638744 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -18,8 +18,8 @@ import { WorkspaceObject, WorkspaceAvailability, } from '../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; import { WorkspaceUseCase } from './types'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; const USE_CASE_PREFIX = 'use-case-'; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index fe88436f38e5..0b7d7c8a57c1 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -77,6 +77,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { ): WorkspaceAttributeWithPermission { return { ...savedObject.attributes, + lastUpdatedTime: savedObject.updated_at, id: savedObject.id, permissions: savedObject.permissions, };