From d7519aaf5749af9eba49e15e5e07808457b6eb21 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 21 Mar 2024 09:55:37 +0800 Subject: [PATCH] [Workspace] Register a workspace dropdown menu at the top of left nav bar (#6150) When workspace is enabled, the workspace plugin will register a workspace dropdown menu via `chrome.registerCollapsibleNavHeader` which displays a list of workspaces, links to create workspace page and workspace list page. --------- Signed-off-by: Yulong Ruan --- src/core/public/index.ts | 6 +- src/core/public/mocks.ts | 1 + src/core/public/utils/index.ts | 1 + src/core/public/workspace/index.ts | 7 +- .../workspace/workspaces_service.mock.ts | 44 ++-- .../public/workspace/workspaces_service.ts | 2 +- src/plugins/workspace/common/constants.ts | 4 +- .../workspace_menu/workspace_menu.test.tsx | 120 +++++++++++ .../workspace_menu/workspace_menu.tsx | 191 ++++++++++++++++++ src/plugins/workspace/public/plugin.test.ts | 7 + src/plugins/workspace/public/plugin.ts | 13 ++ 11 files changed, 370 insertions(+), 26 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index c9d416cb6f43..c82457ef2184 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,7 +94,7 @@ export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ export { CoreContext, CoreSystem } from './core_system'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; export { AppCategory, UiSettingsParams, @@ -357,6 +357,4 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; - -export { WORKSPACE_TYPE } from '../utils'; +export { WorkspacesStart, WorkspacesSetup, WorkspacesService, WorkspaceObject } from './workspace'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 3acc71424b91..05c3b7d18d1b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -74,6 +74,7 @@ function createCoreSetupMock({ } = {}) { const mock = { application: applicationServiceMock.createSetupContract(), + chrome: chromeServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index c0c6f2582e9c..30055b0ff81c 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -36,4 +36,5 @@ export { WORKSPACE_TYPE, formatUrlWithWorkspaceId, getWorkspaceIdFromUrl, + cleanWorkspaceId, } from '../../utils'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index 4b9b2c86f649..712ad657fa65 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -3,4 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; +export { + WorkspacesStart, + WorkspacesService, + WorkspacesSetup, + WorkspaceObject, +} from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ae56c035eb3a..9e8cdfce7393 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -6,27 +6,33 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; -import { WorkspacesService } from './workspaces_service'; -import { WorkspaceAttribute } from '..'; +import { WorkspacesService, WorkspaceObject } from './workspaces_service'; -const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new BehaviorSubject([]); -const currentWorkspace$ = new BehaviorSubject(null); -const initialized$ = new BehaviorSubject(false); - -const createWorkspacesSetupContractMock = () => ({ - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, -}); +const createWorkspacesSetupContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; -const createWorkspacesStartContractMock = () => ({ - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, -}); +const createWorkspacesStartContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; export type WorkspacesServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index cc19b3c79229..e4cf3bc7a826 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -8,7 +8,7 @@ import { isEqual } from 'lodash'; import { CoreService, WorkspaceAttribute } from '../../types'; -type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; +export type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; interface WorkspaceObservables { /** diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 6ae89c0edad5..d2da08acb52d 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; +export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; +export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx new file mode 100644 index 000000000000..c63b232bb232 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { WorkspaceMenu } from './workspace_menu'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from '../../../../../core/public'; + +describe('', () => { + let coreStartMock: CoreStart; + + beforeEach(() => { + coreStartMock = coreMock.createStart(); + coreStartMock.workspaces.initialized$.next(true); + jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { + return `https://test.com/app/${appId}`; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('should display a list of workspaces in the dropdown', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + expect(screen.getByText(/workspace 2/i)).toBeInTheDocument(); + }); + + it('should display current workspace name', () => { + coreStartMock.workspaces.currentWorkspace$.next({ id: 'workspace-1', name: 'workspace 1' }); + render(); + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + }); + + it('should close the workspace dropdown list', async () => { + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByLabelText(/close workspace dropdown/i)).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/close workspace dropdown/i)); + await waitFor(() => { + expect(screen.queryByLabelText(/close workspace dropdown/i)).not.toBeInTheDocument(); + }); + }); + + it('should navigate to the workspace', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/workspace 1/i)); + + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/workspace_overview' + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to create workspace page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/create workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_create'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to workspace list page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/all workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_list'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx new file mode 100644 index 000000000000..5b16b9766b22 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,191 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiListGroup, + EuiListGroupItem, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; + +import { + WORKSPACE_CREATE_APP_ID, + WORKSPACE_LIST_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, +} from '../../../common/constants'; +import { cleanWorkspaceId, formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; + +interface Props { + coreStart: CoreStart; +} + +/** + * Return maximum five workspaces, the current selected workspace + * will be on the top of the list. + */ +function getFilteredWorkspaceList( + workspaceList: WorkspaceObject[], + currentWorkspace: WorkspaceObject | null +): WorkspaceObject[] { + return [ + ...(currentWorkspace ? [currentWorkspace] : []), + ...workspaceList.filter((workspace) => workspace.id !== currentWorkspace?.id), + ].slice(0, 5); +} + +export const WorkspaceMenu = ({ coreStart }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); + const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'Select a workspace', + } + ); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + + const openPopover = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceObject) => { + const workspaceURL = formatUrlWithWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + coreStart.http.basePath + ); + const name = + currentWorkspace?.name === workspace.name ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + name, + key: workspace.id, + icon: , + onClick: () => { + window.location.assign(workspaceURL); + }, + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems: EuiContextMenuPanelItemDescriptor[] = filteredWorkspaceList.map( + workspaceToItem + ); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: WORKSPACE_CREATE_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: WORKSPACE_LIST_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + <> + + + + + ); + + const currentWorkspaceTitle = ( + + + {currentWorkspaceName} + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 7e4a30637758..716038438094 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -126,4 +126,11 @@ describe('Workspace plugin', () => { expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); + + it('#setup register workspace dropdown menu when setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index c1ae0cfaaf1f..f0c82bda90b7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import React from 'react'; import { Plugin, CoreStart, @@ -15,6 +16,7 @@ import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../comm import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -30,6 +32,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } } + public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); @@ -97,6 +100,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }, }); + /** + * Register workspace dropdown selector on the top of left navigation menu + */ + core.chrome.registerCollapsibleNavHeader(() => { + if (!this.coreStart) { + return null; + } + return React.createElement(WorkspaceMenu, { coreStart: this.coreStart }); + }); + return {}; }