diff --git a/changelogs/fragments/7785.yml b/changelogs/fragments/7785.yml new file mode 100644 index 000000000000..219d33e5d831 --- /dev/null +++ b/changelogs/fragments/7785.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Add workspace navigation for default route ([#7785](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7785)) \ No newline at end of file diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index c8582d2439de..88f9a5dd9ff4 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -11,6 +11,8 @@ export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; export const WORKSPACE_LIST_APP_ID = 'workspace_list'; export const WORKSPACE_DETAIL_APP_ID = 'workspace_detail'; export const WORKSPACE_INITIAL_APP_ID = 'workspace_initial'; +export const WORKSPACE_NAVIGATION_APP_ID = 'workspace_navigation'; + /** * Since every workspace always have overview and update page, these features will be selected by default * and can't be changed in the workspace form feature selector diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx index 6ada5490c379..833581f0f54c 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx @@ -45,11 +45,8 @@ const overview = i18n.translate('workspace.detail.overview', { }); function getOwners(currentWorkspace: WorkspaceAttributeWithPermission) { - if (currentWorkspace.permissions) { - const { groups = [], users = [] } = currentWorkspace.permissions.write; - return [...groups, ...users]; - } - return []; + const { groups = [], users = [] } = currentWorkspace?.permissions?.write || {}; + return [...groups, ...users]; } interface WorkspaceDetailPanelProps { diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 1bb0b93c4a12..ab33cd62d075 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -22,6 +22,9 @@ import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.moc import { WorkspacePlugin, WorkspacePluginStartDeps } from './plugin'; import { contentManagementPluginMocks } from '../../content_management/public'; +// Expect 6 app registrations: create, fatal error, detail, initial, navigation, and list apps. +const registrationAppNumber = 6; + describe('Workspace plugin', () => { const mockDependencies: WorkspacePluginStartDeps = { contentManagement: contentManagementPluginMocks.createStartContract(), @@ -40,7 +43,7 @@ describe('Workspace plugin', () => { savedObjectsManagement: savedObjectManagementSetupMock, management: managementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(registrationAppNumber); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); }); @@ -53,7 +56,7 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart, mockDependencies); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(registrationAppNumber); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); @@ -90,7 +93,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { management: managementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(registrationAppNumber); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(2); @@ -215,6 +218,19 @@ describe('Workspace plugin', () => { ); }); + it('#setup should register workspace navigation with a visible application', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock, {}); + + expect(setupMock.application.register).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workspace_navigation', + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + it('#start add workspace detail page to breadcrumbs when start', async () => { const startMock = coreMock.createStart(); const workspaceObject = { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8cf304d03cfb..4731e9d205b7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -31,6 +31,7 @@ import { WORKSPACE_LIST_APP_ID, WORKSPACE_USE_CASES, WORKSPACE_INITIAL_APP_ID, + WORKSPACE_NAVIGATION_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services, WorkspaceUseCase } from './types'; @@ -45,6 +46,7 @@ import { enrichBreadcrumbsWithWorkspace, filterWorkspaceConfigurableApps, getFirstUseCaseOfFeatureConfigs, + getUseCaseUrl, isAppAccessibleInWorkspace, isNavGroupInFeatureConfigs, } from './utils'; @@ -375,6 +377,32 @@ export class WorkspacePlugin workspaceAvailability: WorkspaceAvailability.outsideWorkspace, }); + const registeredUseCases$ = this.registeredUseCases$; + // register workspace navigation + core.application.register({ + id: WORKSPACE_NAVIGATION_APP_ID, + title: '', + chromeless: true, + navLinkStatus: AppNavLinkStatus.hidden, + async mount() { + const [coreStart] = await core.getStartServices(); + const { application, http, workspaces } = coreStart; + const workspace = workspaces.currentWorkspace$.getValue(); + if (workspace) { + const availableUseCases = registeredUseCases$.getValue(); + const currentUseCase = availableUseCases.find( + (useCase) => useCase.id === getFirstUseCaseOfFeatureConfigs(workspace?.features ?? []) + ); + const useCaseUrl = getUseCaseUrl(currentUseCase, workspace, application, http); + application.navigateToUrl(useCaseUrl); + } else { + application.navigateToApp('home'); + } + return () => {}; + }, + workspaceAvailability: WorkspaceAvailability.insideWorkspace, + }); + // workspace list core.application.register({ id: WORKSPACE_LIST_APP_ID, diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index d5aad60ac88d..b71532e36273 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -4,13 +4,17 @@ */ import { OnPostAuthHandler, OnPreRoutingHandler } from 'src/core/server'; -import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { coreMock, httpServerMock, uiSettingsServiceMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; import { getWorkspaceState, updateWorkspaceState } from '../../../core/server/utils'; import * as utilsExports from './utils'; import { SavedObjectsPermissionControl } from './permission_control/client'; describe('Workspace server plugin', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('#setup', async () => { let value; const capabilities = {} as any; @@ -178,6 +182,24 @@ describe('Workspace server plugin', () => { describe('#setUpRedirectPage', () => { const setupMock = coreMock.createSetup(); + const uiSettingsMock = uiSettingsServiceMock.createClient(); + setupMock.getStartServices.mockResolvedValue([ + { + ...coreMock.createStart(), + uiSettings: { + asScopedToClient: () => ({ + ...uiSettingsMock, + get: jest.fn().mockImplementation((key) => { + if (key === 'home:useNewHomePage') { + return Promise.resolve(true); + } + }), + }), + }, + }, + {}, + {}, + ]); const initializerContextConfigMock = coreMock.createPluginInitializerContext({ enabled: true, permission: { @@ -192,7 +214,18 @@ describe('Workspace server plugin', () => { const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); const response = httpServerMock.createResponseFactory(); - it('with / request path', async () => { + it('without / request path', async () => { + const request = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/foo', + }); + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + await registerOnPostAuthFn(request, response, toolKitMock); + expect(toolKitMock.next).toBeCalledTimes(1); + }); + + it('with / request path and no workspaces', async () => { const request = httpServerMock.createOpenSearchDashboardsRequest({ path: '/', }); @@ -205,29 +238,125 @@ describe('Workspace server plugin', () => { }); }); - it('without / request path', async () => { + it('with / request path and one workspace', async () => { const request = httpServerMock.createOpenSearchDashboardsRequest({ - path: '/foo', + path: '/', + }); + const workspaceSetup = await workspacePlugin.setup(setupMock); + const client = workspaceSetup.client; + jest.spyOn(client, 'list').mockResolvedValue({ + success: true, + result: { + total: 1, + per_page: 100, + page: 1, + workspaces: [{ id: 'workspace-1', name: 'workspace-1' }], + }, }); - await workspacePlugin.setup(setupMock); const toolKitMock = httpServerMock.createToolkit(); await registerOnPostAuthFn(request, response, toolKitMock); - expect(toolKitMock.next).toBeCalledTimes(1); + expect(response.redirected).toBeCalledWith({ + headers: { + location: '/mock-server-basepath/w/workspace-1/app/workspace_navigation', + }, + }); + }); + + it('with / request path and more than one workspaces', async () => { + const request = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/', + }); + const workspaceSetup = await workspacePlugin.setup(setupMock); + const client = workspaceSetup.client; + jest.spyOn(client, 'list').mockResolvedValue({ + success: true, + result: { + total: 2, + per_page: 100, + page: 1, + workspaces: [ + { id: 'workspace-1', name: 'workspace-1' }, + { id: 'workspace-2', name: 'workspace-2' }, + ], + }, + }); + const toolKitMock = httpServerMock.createToolkit(); + + await registerOnPostAuthFn(request, response, toolKitMock); + expect(response.redirected).toBeCalledWith({ + headers: { + location: '/mock-server-basepath/app/home', + }, + }); }); - it('with more than one workspace', async () => { + it('with / request path and default workspace', async () => { const request = httpServerMock.createOpenSearchDashboardsRequest({ path: '/', }); + setupMock.getStartServices.mockResolvedValue([ + { + ...coreMock.createStart(), + uiSettings: { + asScopedToClient: () => ({ + ...uiSettingsMock, + get: jest.fn().mockImplementation((key) => { + if (key === 'defaultWorkspace') { + return Promise.resolve('defaultWorkspace'); + } else if (key === 'home:useNewHomePage') { + return Promise.resolve('true'); + } + }), + }), + }, + }, + {}, + {}, + ]); const workspaceSetup = await workspacePlugin.setup(setupMock); const client = workspaceSetup.client; jest.spyOn(client, 'list').mockResolvedValue({ success: true, - result: { total: 1 }, + result: { + total: 2, + per_page: 100, + page: 1, + workspaces: [ + { id: 'defaultWorkspace', name: 'default-workspace' }, + { id: 'workspace-2', name: 'workspace-2' }, + ], + }, }); const toolKitMock = httpServerMock.createToolkit(); + await registerOnPostAuthFn(request, response, toolKitMock); + expect(response.redirected).toBeCalledWith({ + headers: { + location: '/mock-server-basepath/w/defaultWorkspace/app/workspace_navigation', + }, + }); + }); + + it('with / request path and home:useNewHomePage is false', async () => { + const request = httpServerMock.createOpenSearchDashboardsRequest({ + path: '/', + }); + setupMock.getStartServices.mockResolvedValue([ + { + ...coreMock.createStart(), + uiSettings: { + asScopedToClient: () => ({ + ...uiSettingsMock, + get: jest.fn().mockResolvedValue(false), + }), + }, + }, + {}, + {}, + ]); + const toolKitMock = httpServerMock.createToolkit(); + await registerOnPostAuthFn(request, response, toolKitMock); expect(toolKitMock.next).toBeCalledTimes(1); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 6d9c46ed9dc8..d7c1ba69206c 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -23,6 +23,7 @@ import { WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID, PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER, WORKSPACE_INITIAL_APP_ID, + WORKSPACE_NAVIGATION_APP_ID, } from '../common/constants'; import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; @@ -113,14 +114,51 @@ export class WorkspacePlugin implements Plugin { const path = request.url.pathname; if (path === '/') { + const [coreStart] = await core.getStartServices(); + const uiSettings = coreStart.uiSettings.asScopedToClient( + coreStart.savedObjects.getScopedClient(request) + ); + const useNewHomePage = await uiSettings.get('home:useNewHomePage'); + if (!useNewHomePage) { + return toolkit.next(); + } + const workspaceListResponse = await this.client?.list( { request, logger: this.logger }, - { page: 1, perPage: 1 } + { page: 1, perPage: 100 } ); + const basePath = core.http.basePath.serverBasePath; + if (workspaceListResponse?.success && workspaceListResponse.result.total > 0) { - return toolkit.next(); + const workspaceList = workspaceListResponse.result.workspaces; + // If user only has one workspace, go to overview page of that workspace + if (workspaceList.length === 1) { + return response.redirected({ + headers: { + location: `${basePath}/w/${workspaceList[0].id}/app/${WORKSPACE_NAVIGATION_APP_ID}`, + }, + }); + } + // Temporarily use defaultWorkspace as a placeholder + const defaultWorkspaceId = await uiSettings.get('defaultWorkspace'); + const defaultWorkspace = workspaceList.find( + (workspace) => workspace.id === defaultWorkspaceId + ); + // If user has a default workspace configured, go to overview page of that workspace + // If user has more than one workspaces, go to homepage + if (defaultWorkspace) { + return response.redirected({ + headers: { + location: `${basePath}/w/${defaultWorkspace.id}/app/${WORKSPACE_NAVIGATION_APP_ID}`, + }, + }); + } else { + return response.redirected({ + headers: { location: `${basePath}/app/home` }, + }); + } } - const basePath = core.http.basePath.serverBasePath; + // If user has no workspaces, go to initial page return response.redirected({ headers: { location: `${basePath}/app/${WORKSPACE_INITIAL_APP_ID}` }, });