diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 691ba64cf00a..ca15c76377d4 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -44,8 +44,16 @@ import { httpServiceMock } from '../http/http_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; -import { App, PublicAppInfo, AppNavLinkStatus, AppStatus, AppUpdater } from './types'; +import { + App, + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + AppUpdater, + WorkspaceAccessibility, +} from './types'; import { act } from 'react-dom/test-utils'; +import { workspacesServiceMock } from '../mocks'; const createApp = (props: Partial): App => { return { @@ -68,7 +76,11 @@ describe('#setup()', () => { context: contextServiceMock.createSetupContract(), redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -398,7 +410,11 @@ describe('#start()', () => { context: contextServiceMock.createSetupContract(), redirectTo: jest.fn(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); @@ -539,6 +555,32 @@ describe('#start()', () => { 'http://localhost/base-path/app/app2/deep/link' ); }); + + it('creates URL when the app is not accessible in a workspace', async () => { + const httpMock = httpServiceMock.createSetupContract({ + basePath: '/base-path', + clientBasePath: '/client-base-path', + }); + const { register } = service.setup({ + ...setupDeps, + http: httpMock, + }); + // register a app that can only be accessed out of a workspace + register( + Symbol(), + createApp({ + id: 'app1', + workspaceAccessibility: WorkspaceAccessibility.NO, + }) + ); + const { getUrlForApp } = await service.start({ + ...startDeps, + http: httpMock, + }); + + expect(getUrlForApp('app1')).toBe('/base-path/app/app1'); + expect(getUrlForApp('app2')).toBe('/base-path/client-base-path/app/app2'); + }); }); describe('navigateToApp', () => { @@ -766,6 +808,46 @@ describe('#start()', () => { `); }); + it('navigate by using window.location.assign if navigate to a app not accessible within a workspace', async () => { + // Save the original assign method + const originalLocation = window.location; + delete (window as any).location; + + // Mocking the window object + window.location = { + ...originalLocation, + assign: jest.fn(), + }; + + const { register } = service.setup(setupDeps); + // register a app that can only be accessed out of a workspace + register( + Symbol(), + createApp({ + id: 'app1', + workspaceAccessibility: WorkspaceAccessibility.NO, + }) + ); + const workspaces = workspacesServiceMock.createStartContract(); + workspaces.currentWorkspaceId$.next('foo'); + const http = httpServiceMock.createStartContract({ + basePath: '/base-path', + clientBasePath: '/client-base-path', + }); + const { navigateToApp } = await service.start({ + ...startDeps, + workspaces, + http, + }); + await navigateToApp('app1'); + + expect(window.location.assign).toBeCalledWith('/base-path/app/app1'); + await navigateToApp('app2'); + // assign should not be called + expect(window.location.assign).toBeCalledTimes(1); + window.location = originalLocation; + }); + describe('when `replace` option is true', () => { it('use `history.replace` instead of `history.push`', async () => { service.setup(setupDeps); @@ -869,7 +951,11 @@ describe('#stop()', () => { http, context: contextServiceMock.createSetupContract(), }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 62c13694e245..3dca2cfd46a1 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -54,9 +54,11 @@ import { InternalApplicationStart, Mounter, NavigateToAppOptions, + WorkspaceAccessibility, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils'; +import { WorkspacesStart } from '../workspace'; interface SetupDeps { context: ContextSetup; @@ -69,6 +71,7 @@ interface SetupDeps { interface StartDeps { http: HttpStart; overlays: OverlayStart; + workspaces: WorkspacesStart; } // Mount functions with two arguments are assumed to expect deprecated `context` object. @@ -213,7 +216,7 @@ export class ApplicationService { }; } - public async start({ http, overlays }: StartDeps): Promise { + public async start({ http, overlays, workspaces }: StartDeps): Promise { if (!this.mountContext) { throw new Error('ApplicationService#setup() must be invoked before start.'); } @@ -259,6 +262,22 @@ export class ApplicationService { const shouldNavigate = navigatingToSameApp ? true : await this.shouldNavigate(overlays); if (shouldNavigate) { + const targetApp = applications$.value.get(appId); + if ( + workspaces.currentWorkspaceId$.value && + targetApp?.workspaceAccessibility === WorkspaceAccessibility.NO + ) { + // If user is inside a workspace and the target app is not accessible within a workspace + // refresh the page by doing a hard navigation + window.location.assign( + http.basePath.prepend(getAppUrl(availableMounters, appId, path), { + // Set withoutClientBasePath to true remove the workspace path prefix + withoutClientBasePath: true, + }) + ); + return; + } + if (path === undefined) { path = applications$.value.get(appId)?.defaultPath; } @@ -293,7 +312,10 @@ export class ApplicationService { appId, { path, absolute = false }: { path?: string; absolute?: boolean } = {} ) => { - const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path)); + const targetApp = applications$.value.get(appId); + const relUrl = http.basePath.prepend(getAppUrl(availableMounters, appId, path), { + withoutClientBasePath: targetApp?.workspaceAccessibility === WorkspaceAccessibility.NO, + }); return absolute ? relativeToAbsolute(relUrl) : relUrl; }, navigateToApp, diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index b1a9f47b72e0..790c6ac9240c 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -54,4 +54,5 @@ export { // Internal types InternalApplicationSetup, InternalApplicationStart, + WorkspaceAccessibility, } from './types'; diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 9d53d99c9d8c..a463dec892f3 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -41,6 +41,7 @@ import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; import { Observable } from 'rxjs'; import { MountPoint } from 'opensearch-dashboards/public'; +import { workspacesServiceMock } from '../../mocks'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -67,7 +68,11 @@ describe('ApplicationService', () => { context: contextServiceMock.createSetupContract(), history: history as any, }; - startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + startDeps = { + http, + overlays: overlayServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), + }; service = new ApplicationService(); }); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4744ab34cfd3..977489fda37a 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -103,6 +103,22 @@ export type AppUpdatableFields = Pick Partial | undefined; +/** + * Visibilities of the application based on if user is within a workspace + * + * @public + */ +export enum WorkspaceAccessibility { + /** + * The application is not accessible when user is in a workspace. + */ + NO = 0, + /** + * The application is only accessible when user is in a workspace. + */ + YES = 1, +} + /** * @public */ @@ -245,6 +261,12 @@ export interface App { * ``` */ exactRoute?: boolean; + + /** + * Declare if page is accessible when inside a workspace. + * Defaults to undefined to indicate the application can be accessible within or out of workspace. + */ + workspaceAccessibility?: WorkspaceAccessibility; } /** diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 57c9f11d9061..59ca1d29be50 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -264,7 +264,7 @@ export class ChromeService { forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} - homeHref={http.basePath.prepend('/app/home')} + homeHref={application.getUrlForApp('home')} isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts index 1fe2532b7d8f..85ff753a40d9 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.test.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -28,7 +28,12 @@ * under the License. */ -import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application'; +import { + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + WorkspaceAccessibility, +} from '../../application'; import { toNavLink } from './to_nav_link'; import { httpServiceMock } from '../../mocks'; @@ -174,4 +179,38 @@ describe('toNavLink', () => { }) ); }); + + it('uses the workspaceVisibility of the application to construct the url', () => { + const httpMock = httpServiceMock.createStartContract({ + basePath: '/base_path', + clientBasePath: '/client_base_path', + }); + expect( + toNavLink( + app({ + workspaceAccessibility: WorkspaceAccessibility.NO, + }), + httpMock.basePath + ).properties + ).toEqual( + expect.objectContaining({ + url: 'http://localhost/base_path/app/some-id', + baseUrl: 'http://localhost/base_path/app/some-id', + }) + ); + + expect( + toNavLink( + app({ + workspaceAccessibility: WorkspaceAccessibility.YES, + }), + httpMock.basePath + ).properties + ).toEqual( + expect.objectContaining({ + url: 'http://localhost/base_path/client_base_path/app/some-id', + baseUrl: 'http://localhost/base_path/client_base_path/app/some-id', + }) + ); + }); }); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts index 734bb114d73d..3b03b6d9278e 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -28,14 +28,22 @@ * under the License. */ -import { PublicAppInfo, AppNavLinkStatus, AppStatus } from '../../application'; +import { + PublicAppInfo, + AppNavLinkStatus, + AppStatus, + WorkspaceAccessibility, +} from '../../application'; import { IBasePath } from '../../http'; import { NavLinkWrapper } from './nav_link'; import { appendAppPath } from '../../application/utils'; export function toNavLink(app: PublicAppInfo, basePath: IBasePath): NavLinkWrapper { const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; - const relativeBaseUrl = basePath.prepend(app.appRoute!); + let relativeBaseUrl = basePath.prepend(app.appRoute!); + if (app.workspaceAccessibility === WorkspaceAccessibility.NO) { + relativeBaseUrl = basePath.prepend(app.appRoute!, { withoutClientBasePath: true }); + } const url = relativeToAbsolute(appendAppPath(relativeBaseUrl, app.defaultPath)); const baseUrl = relativeToAbsolute(relativeBaseUrl); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index e70966620c82..7f95f1d45643 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -226,8 +226,8 @@ export class CoreSystem { overlays, targetDomElement: notificationsTargetDomElement, }); - const application = await this.application.start({ http, overlays }); const workspaces = this.workspaces.start(); + const application = await this.application.start({ http, overlays, workspaces }); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 0dc37587032e..9d23545c20fe 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -130,6 +130,7 @@ export { AppUpdater, ScopedHistory, NavigateToAppOptions, + WorkspaceAccessibility, } from './application'; export { diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index bf815a30c74d..95f1a511d43a 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -54,7 +54,7 @@ import { DataPublicPluginStart } from '../../data/public'; import { TelemetryPluginStart } from '../../telemetry/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; -import { AppNavLinkStatus } from '../../../core/public'; +import { AppNavLinkStatus, WorkspaceAccessibility } from '../../../core/public'; import { PLUGIN_ID, HOME_APP_BASE_PATH } from '../common/constants'; import { DataSourcePluginStart } from '../../data_source/public'; @@ -130,6 +130,7 @@ export class HomePublicPlugin const { renderApp } = await import('./application'); return await renderApp(params.element, coreStart, params.history); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); urlForwarding.forwardApp('home', 'home'); diff --git a/src/plugins/opensearch_dashboards_overview/public/plugin.ts b/src/plugins/opensearch_dashboards_overview/public/plugin.ts index e38282ff06d6..69b0e9616452 100644 --- a/src/plugins/opensearch_dashboards_overview/public/plugin.ts +++ b/src/plugins/opensearch_dashboards_overview/public/plugin.ts @@ -40,6 +40,7 @@ import { AppStatus, AppNavLinkStatus, Branding, + WorkspaceAccessibility, } from '../../../core/public'; import { OpenSearchDashboardsOverviewPluginSetup, @@ -106,6 +107,7 @@ export class OpenSearchDashboardsOverviewPlugin // Render the application return renderApp(coreStart, depsStart as AppPluginStartDependencies, params); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); if (home) { 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 index c63b232bb232..31682b6f649a 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -93,7 +93,7 @@ describe('', () => { 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'); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_create'); Object.defineProperty(window, 'location', { value: originalLocation, @@ -111,7 +111,7 @@ describe('', () => { 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'); + expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('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 index a5b250e6b89c..495c35731450 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -101,13 +101,7 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { }), key: WORKSPACE_CREATE_APP_ID, onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }) - ) - ); + coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); }, }); workspaceListItems.push({ @@ -117,13 +111,7 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { }), key: WORKSPACE_LIST_APP_ID, onClick: () => { - window.location.assign( - cleanWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { - absolute: false, - }) - ) - ); + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); }, }); return workspaceListItems; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 95e684610b42..240e10c24570 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -14,6 +14,7 @@ import { Plugin, AppUpdater, AppStatus, + WorkspaceAccessibility, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -140,6 +141,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { const { renderListApp } = await import('./application'); return mountWorkspaceApp(params, renderListApp); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); // create @@ -153,6 +155,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { const { renderCreatorApp } = await import('./application'); return mountWorkspaceApp(params, renderCreatorApp); }, + workspaceAccessibility: WorkspaceAccessibility.NO, }); // update diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 046ada41b11c..d2b9909eb682 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -8,7 +8,7 @@ import { getSelectedFeatureQuantities, isAppAccessibleInWorkspace, } from './utils'; -import { PublicAppInfo } from '../../../core/public'; +import { PublicAppInfo, WorkspaceAccessibility } from '../../../core/public'; import { AppNavLinkStatus } from '../../../core/public'; describe('workspace utils: featureMatchesConfig', () => { @@ -198,4 +198,18 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { ) ).toBe(true); }); + + it('An app is not accessible if its workspaceAccessibility is no', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'home', + title: 'Any app', + mount: jest.fn(), + workspaceAccessibility: WorkspaceAccessibility.NO, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(false); + }); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 327720be192e..e65d62a58396 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -10,6 +10,7 @@ import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES, WorkspaceObject, + WorkspaceAccessibility, } from '../../../core/public'; /** @@ -94,6 +95,13 @@ export const getSelectedFeatureQuantities = ( * Check if an app is accessible in a workspace based on the workspace configured features */ export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) { + /** + * App is not accessible within workspace if it explicitly declare itself as workspaceAccessibility.No + */ + if (app.workspaceAccessibility === WorkspaceAccessibility.NO) { + return false; + } + /** * When workspace has no features configured, all apps are considered to be accessible */