From 2ed4e83b89c8fc659e999ddfd0e86e73e50a6327 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Thu, 18 Jul 2024 11:13:15 +0800 Subject: [PATCH] workspace list card Signed-off-by: Hailong Cui --- src/core/types/workspace.ts | 1 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- .../workspace_list_card.test.tsx.snap | 126 ++++++++++ .../public/components/service_card/index.ts | 6 + .../service_card/workspace_list_card.test.tsx | 91 +++++++ .../service_card/workspace_list_card.tsx | 238 ++++++++++++++++++ src/plugins/workspace/public/plugin.ts | 30 ++- src/plugins/workspace/public/utils.ts | 29 ++- .../workspace/server/workspace_client.ts | 1 + 10 files changed, 522 insertions(+), 4 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/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/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index a1f9e7d38e40..04b0acd4bfe7 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -184,3 +184,5 @@ export const WORKSPACE_USE_CASES = Object.freeze({ }); 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..79dff7504bc5 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"], + "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement","contentManagement"], "requiredBundles": ["opensearchDashboardsReact"] } 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..bfd75c554ad1 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`workspace list card render normally should show workspace list card correctly 1`] = ` +
+
+
+
+

+ Workspaces +

+
+
+ + + +
+
+
+
+ +
+ + +
+
+
+
+
+
+
    +
    +
    + +
    +
    +
    +
    + a few seconds ago +
    +
    +
    +
    +
+ +
+
+`; 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..a11ec8dce1a5 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { fireEvent, render } from '@testing-library/react'; +import { WorkspaceListCard } from './workspace_list_card'; +import { addRecentWorkspace } from '../../utils'; + +interface LooseObject { + [key: string]: any; +} + +// Mock localStorage +const localStorageMock = (() => { + let store = {} as LooseObject; + return { + getItem(key: string) { + return store[key] || null; + }, + setItem(key: string, value: string) { + store[key] = value.toString(); + }, + removeItem(key: string) { + delete store[key]; + }, + clear() { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +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); + addRecentWorkspace('foo'); + }); + + it('should show workspace list card correctly', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should show default filter as recently viewed', () => { + const { getByTestId, getByText } = render(); + expect(getByTestId('workspace_filter')).toHaveDisplayValue('Recently viewed'); + + expect(getByText('foo')).toBeInTheDocument(); + }); + + it('should show empty state if no recently viewed workspace', () => { + localStorageMock.clear(); + + 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 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..2c4941de9a42 --- /dev/null +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx @@ -0,0 +1,238 @@ +/* + * 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 { switchWorkspace } from '../utils/workspace'; + +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; +import { getRecentWorkspaces, LastVisitWorkspace } from '../../utils'; + +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; +} + +export interface WorkspaceListCardState { + availiableWorkspaces: WorkspaceObject[]; + filter: string; + workspaceList: WorkspaceListItem[]; + recentWorkspaces: LastVisitWorkspace[]; +} + +export class WorkspaceListCard extends Component { + private workspaceSub?: Subscription; + constructor(props: WorkspaceListCardProps) { + super(props); + this.state = { + availiableWorkspaces: [], + recentWorkspaces: [], + workspaceList: [], + filter: 'viewed', + }; + } + + componentDidMount() { + this.setState({ + recentWorkspaces: 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: _.sortBy(this.state.recentWorkspaces, ['visitTime']) + .filter((ws) => this.state.availiableWorkspaces.some((a) => a.name === ws.workspaceName)) + .slice(0, MAX_ITEM_IN_LIST) + .map((ws) => ({ + id: this.state.availiableWorkspaces.find((a) => a.name === ws.workspaceName)!.id, + name: ws.workspaceName, + time: ws.visitTime, + })), + }); + } else if (this.state.filter === 'updated') { + this.setState({ + workspaceList: _.sortBy(this.state.availiableWorkspaces, ['lastUpdatedTime']) + .sort() + .reverse() + .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) { + switchWorkspace({ 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 ? ( + + ) : ( + ({ + 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 d8fdc7b87496..01ab1ac6ab92 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -34,6 +34,7 @@ import { Services } 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'; @@ -42,6 +43,7 @@ import { isAppAccessibleInWorkspace, isNavGroupInFeatureConfigs, } from './utils'; +import { WorkspaceListCard } from './components/service_card'; type WorkspaceAppType = ( params: AppMountParameters, @@ -55,7 +57,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; @@ -367,7 +374,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(); @@ -381,9 +388,28 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> this.addWorkspaceToBreadcrumbs(core); } + // register workspace list in home page + this.registerWorkspaceListToHome(core, contentManagement); + return {}; } + private registerWorkspaceListToHome( + core: CoreStart, + contentManagement: ContentManagementPluginStart + ) { + const homePage = contentManagement?.getPage('home'); + const serviceCards = homePage?.getSections().find((section) => section.id === 'service_cards'); + if (serviceCards && homePage) { + homePage.addContent('service_cards', { + id: 'workspace_list', + kind: 'custom', + order: 0, + render: () => React.createElement(WorkspaceListCard, { core }), + }); + } + } + 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 da9987b2aa1a..637054927cab 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -13,7 +13,11 @@ import { WorkspaceObject, WorkspaceAvailability, } from '../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_USE_CASES } from '../common/constants'; +import { + DEFAULT_SELECTED_FEATURES_IDS, + RECENT_WORKSPACES_KEY, + WORKSPACE_USE_CASES, +} from '../common/constants'; const USE_CASE_PREFIX = 'use-case-'; @@ -178,6 +182,29 @@ export const filterWorkspaceConfigurableApps = (applications: PublicAppInfo[]) = return visibleApplications; }; +export interface LastVisitWorkspace { + workspaceName: string; + visitTime: string; +} + +// Get recently accessed workspaces from the browser local storage. +export const getRecentWorkspaces = (): LastVisitWorkspace[] => { + const storedWorkspaces = localStorage.getItem(RECENT_WORKSPACES_KEY); + return storedWorkspaces ? JSON.parse(storedWorkspaces) : []; +}; + +// Set recently accessed workspace in the browser local storage. +export const addRecentWorkspace = (newWorkspace: string) => { + const workspaces = getRecentWorkspaces(); + // Put the latest visited workspace at the front. + const updatedWorkspaces = [ + { workspaceName: newWorkspace, visitTime: new Date().toISOString() }, + ...workspaces.filter((ws) => ws.workspaceName !== newWorkspace), + ]; + localStorage.setItem(RECENT_WORKSPACES_KEY, JSON.stringify(updatedWorkspaces)); + return updatedWorkspaces; +}; + export const getDataSourcesList = (client: SavedObjectsStart['client'], workspaces: string[]) => { return client .find({ diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 159136e1304f..45ed13d34e22 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, };