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/content_management/public/components/page_render.tsx b/src/plugins/content_management/public/components/page_render.tsx index 541e8713c926..a64b3c060f49 100644 --- a/src/plugins/content_management/public/components/page_render.tsx +++ b/src/plugins/content_management/public/components/page_render.tsx @@ -6,10 +6,10 @@ import React from 'react'; import { useObservable } from 'react-use'; +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; import { Page } from '../services'; import { SectionRender } from './section_render'; import { EmbeddableStart } from '../../../embeddable/public'; -import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; export interface Props { page: Page; @@ -24,6 +24,7 @@ export const PageRender = ({ page, embeddable, savedObjectsClient }: Props) => {
{sections.map((section) => ( +
+
+
+

+ 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..4774f69cba1f --- /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 = 6; + +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: this.state.recentWorkspaces + .sort() + .reverse() + .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: this.state.availiableWorkspaces + .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/components/workspace_overview/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx index 63073bbce8c6..8216740692fa 100644 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx @@ -26,7 +26,7 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { WorkspaceOverviewSettings } from './workspace_overview_settings'; import { WorkspaceOverviewContent } from './workspace_overview_content'; import { getStartCards } from './all_get_started_cards'; -import { isAppAccessibleInWorkspace } from '../../utils'; +import { addRecentWorkspace, isAppAccessibleInWorkspace } from '../../utils'; import { WorkspaceOverviewCard } from './getting_start_card'; import { WorkspaceOverviewGettingStartModal } from './getting_start_modal'; @@ -56,6 +56,12 @@ export const WorkspaceOverview = (props: WorkspaceOverviewProps) => { setIsGettingStartCardsCollapsed(localStorage.getItem(workspaceOverviewCollapsedKey) === 'true'); }, [workspaceOverviewCollapsedKey]); + useEffect(() => { + if (currentWorkspace?.name) { + addRecentWorkspace(currentWorkspace.name); + } + }); + /** * all available cards based on workspace selected features */ diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 2b798b1c5073..261d899e52d3 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 { ContentManagementPluginSetup } 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, @@ -53,6 +55,7 @@ interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; management?: ManagementSetup; dataSourceManagement?: DataSourceManagementPluginSetup; + contentManagement?: ContentManagementPluginSetup; } export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { @@ -65,6 +68,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private navGroupUpdater$ = new BehaviorSubject(() => undefined); private workspaceConfigurableApps$ = new BehaviorSubject([]); private unregisterNavGroupUpdater?: () => void; + private contentManagement?: ContentManagementPluginSetup; private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { @@ -184,8 +188,9 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> public async setup( core: CoreSetup, - { savedObjectsManagement, management, dataSourceManagement }: WorkspacePluginSetupDeps + { savedObjectsManagement, management, contentManagement, dataSourceManagement }: WorkspacePluginSetupDeps ) { + this.contentManagement = contentManagement; const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); core.application.registerAppUpdater(this.appUpdater$); @@ -368,9 +373,25 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> this.addWorkspaceToBreadcrumbs(core); } + // register workspace list in home page + this.registerWorkspaceListToHome(core); + return {}; } + private registerWorkspaceListToHome(core: CoreStart) { + const homePage = this.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, };