diff --git a/CHANGELOG.md b/CHANGELOG.md index fe283b39b41a..71d938635e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add delete saved objects by workspace functionality([#6013](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6013)) - [Workspace] Add a workspace client in workspace plugin ([#6094](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6094)) - [Multiple Datasource] Add component to show single selected data source in read only mode ([#6125](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6125)) +- [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) ### 🐛 Bug Fixes diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 7b4e3ba472dc..62f00bee2c74 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -54,7 +54,9 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -2005,7 +2007,9 @@ exports[`CollapsibleNav renders the default nav 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -2307,7 +2311,9 @@ exports[`CollapsibleNav renders the default nav 2`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -2610,7 +2616,9 @@ exports[`CollapsibleNav renders the default nav 3`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -3204,7 +3212,9 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -4319,7 +4329,9 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -5433,7 +5445,9 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -6540,7 +6554,9 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 7ec470c74e03..8d244a212d1f 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -242,7 +242,9 @@ exports[`Header handles visibility and lock changes 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -5873,7 +5875,9 @@ exports[`Header handles visibility and lock changes 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -6934,7 +6938,9 @@ exports[`Header renders condensed header 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", @@ -11346,7 +11352,9 @@ exports[`Header renders condensed header 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 27cfa9bf0581..921ec13e6db2 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -110,4 +110,51 @@ describe('BasePath', () => { expect(new BasePath('/foo/bar', '/foo').serverBasePath).toEqual('/foo'); }); }); + + describe('clientBasePath', () => { + it('get with clientBasePath provided when construct', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').get()).toEqual( + '/foo/bar/client_base_path' + ); + }); + + it('getBasePath with clientBasePath provided when construct', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').getBasePath()).toEqual( + '/foo/bar' + ); + }); + + it('prepend with clientBasePath provided when construct', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').prepend('/prepend')).toEqual( + '/foo/bar/client_base_path/prepend' + ); + }); + + it('construct with clientBasePath provided but calls prepend with withoutClientBasePath is true', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/client_base_path').prepend('/prepend', { + withoutClientBasePath: true, + }) + ).toEqual('/foo/bar/prepend'); + }); + + it('remove with clientBasePath provided when construct', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/client_base_path').remove( + '/foo/bar/client_base_path/remove' + ) + ).toEqual('/remove'); + }); + + it('construct with clientBasePath provided but calls remove with withoutClientBasePath is true', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/client_base_path').remove( + '/foo/bar/client_base_path/remove', + { + withoutClientBasePath: true, + } + ) + ).toEqual('/client_base_path/remove'); + }); + }); }); diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index b31504676dba..c88602c35b9d 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -29,37 +29,47 @@ */ import { modifyUrl } from '@osd/std'; +import type { PrependOptions } from './types'; export class BasePath { constructor( private readonly basePath: string = '', - public readonly serverBasePath: string = basePath + public readonly serverBasePath: string = basePath, + private readonly clientBasePath: string = '' ) {} public get = () => { + return `${this.basePath}${this.clientBasePath}`; + }; + + public getBasePath = () => { return this.basePath; }; - public prepend = (path: string): string => { - if (!this.basePath) return path; + public prepend = (path: string, prependOptions?: PrependOptions): string => { + const { withoutClientBasePath } = prependOptions || {}; + const basePath = withoutClientBasePath ? this.basePath : this.get(); + if (!basePath) return path; return modifyUrl(path, (parts) => { if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { - parts.pathname = `${this.basePath}${parts.pathname}`; + parts.pathname = `${basePath}${parts.pathname}`; } }); }; - public remove = (path: string): string => { - if (!this.basePath) { + public remove = (path: string, prependOptions?: PrependOptions): string => { + const { withoutClientBasePath } = prependOptions || {}; + const basePath = withoutClientBasePath ? this.basePath : this.get(); + if (!basePath) { return path; } - if (path === this.basePath) { + if (path === basePath) { return '/'; } - if (path.startsWith(`${this.basePath}/`)) { - return path.slice(this.basePath.length); + if (path.startsWith(`${basePath}/`)) { + return path.slice(basePath.length); } return path; diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 8c10d10017e5..b34b4d1cfa88 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -39,7 +39,7 @@ export type HttpSetupMock = jest.Mocked & { anonymousPaths: jest.Mocked; }; -const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ basePath = '', clientBasePath = '' } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -48,7 +48,7 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath), + basePath: new BasePath(basePath, undefined, clientBasePath), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), @@ -58,14 +58,14 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ intercept: jest.fn(), }); -const createMock = ({ basePath = '' } = {}) => { +const createMock = ({ basePath = '', clientBasePath = '' } = {}) => { const mocked: jest.Mocked> = { setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockReturnValue(createServiceMock({ basePath })); - mocked.start.mockReturnValue(createServiceMock({ basePath })); + mocked.setup.mockReturnValue(createServiceMock({ basePath, clientBasePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath, clientBasePath })); return mocked; }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index e60e506dfc0a..5671064e4c52 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -74,6 +74,32 @@ describe('#setup()', () => { // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); }); + + it('setup basePath without workspaceId provided in window.location.href', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual(''); + }); + + it('setup basePath with workspaceId provided in window.location.href', () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); + windowSpy.mockRestore(); + }); }); describe('#stop()', () => { diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index f26323f261aa..6832703c7925 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -36,6 +36,8 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { getWorkspaceIdFromUrl } from '../utils'; +import { WORKSPACE_PATH_PREFIX } from '../../utils/constants'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -50,9 +52,15 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); + let clientBasePath = ''; + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + clientBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + } const basePath = new BasePath( injectedMetadata.getBasePath(), - injectedMetadata.getServerBasePath() + injectedMetadata.getServerBasePath(), + clientBasePath ); const fetchService = new Fetch({ basePath, opensearchDashboardsVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index f2573a6badd5..6e93e1cee94a 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -87,25 +87,43 @@ export interface HttpSetup { */ export type HttpStart = HttpSetup; +/** + * prepend options + * + * withoutClientBasePath option will prepend a relative url with serverBasePath only. + * For now, clientBasePath is consist of: + * workspacePath, which has the pattern of /w/{workspaceId}. + * + * In the future, clientBasePath may have other parts but keep `withoutClientBasePath` for now to not over-design the interface, + */ +export interface PrependOptions { + withoutClientBasePath?: boolean; +} + /** * APIs for manipulating the basePath on URL segments. * @public */ export interface IBasePath { /** - * Gets the `basePath` string. + * Gets the `basePath + clientBasePath` string. */ get: () => string; /** - * Prepends `path` with the basePath. + * Gets the `basePath` string + */ + getBasePath: () => string; + + /** + * Prepends `path` with the basePath + clientBasePath. */ - prepend: (url: string) => string; + prepend: (url: string, prependOptions?: PrependOptions) => string; /** - * Removes the prepended basePath from the `path`. + * Removes the prepended basePath + clientBasePath from the `path`. */ - remove: (url: string) => string; + remove: (url: string, prependOptions?: PrependOptions) => string; /** * Returns the server's root basePath as configured, without any namespace prefix. diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 4e889ff82e6a..4140603ff6f7 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -351,4 +351,6 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { WorkspacesStart, WorkspacesSetup } from './workspace'; +export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; + +export { WORKSPACE_TYPE } from '../utils'; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 7676b9482aac..c0c6f2582e9c 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,3 +31,9 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; +export { + WORKSPACE_PATH_PREFIX, + WORKSPACE_TYPE, + formatUrlWithWorkspaceId, + getWorkspaceIdFromUrl, +} from '../../utils'; diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d2c9e0086ad7..42b01e72b0d1 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -32,3 +32,4 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; export * from './streams'; +export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils'; diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 73c2d6010846..ecc1b7e863c4 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -4,3 +4,5 @@ */ export const WORKSPACE_TYPE = 'workspace'; + +export const WORKSPACE_PATH_PREFIX = '/w'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index af4f9a17ae58..a83f85a8fce0 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,4 +37,5 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_TYPE } from './constants'; +export { WORKSPACE_PATH_PREFIX, WORKSPACE_TYPE } from './constants'; +export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts new file mode 100644 index 000000000000..a852ddcc5190 --- /dev/null +++ b/src/core/utils/workspace.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId } from './workspace'; +import { httpServiceMock } from '../public/mocks'; + +describe('#getWorkspaceIdFromUrl', () => { + it('return workspace when there is a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w/foo')).toEqual('foo'); + }); + + it('return empty when there is not a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w2/foo')).toEqual(''); + }); + + it('return workspace when there is a match with basePath provided', () => { + expect(getWorkspaceIdFromUrl('http://localhost/basepath/w/foo', '/basepath')).toEqual('foo'); + }); + + it('return empty when there is a match without basePath but basePath provided', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w/foo', '/w')).toEqual(''); + }); +}); + +describe('#formatUrlWithWorkspaceId', () => { + const basePathWithoutClientBasePath = httpServiceMock.createSetupContract().basePath; + it('return url with workspace prefix when format with a id provided', () => { + expect( + formatUrlWithWorkspaceId('/app/dashboard', 'foo', basePathWithoutClientBasePath) + ).toEqual('http://localhost/w/foo/app/dashboard'); + }); + + it('return url without workspace prefix when format without a id', () => { + expect( + formatUrlWithWorkspaceId('/w/foo/app/dashboard', '', basePathWithoutClientBasePath) + ).toEqual('http://localhost/app/dashboard'); + }); +}); diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts new file mode 100644 index 000000000000..c383967483a8 --- /dev/null +++ b/src/core/utils/workspace.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_PATH_PREFIX } from './constants'; +import { IBasePath } from '../public'; + +export const getWorkspaceIdFromUrl = (url: string, basePath?: string): string => { + const regexp = new RegExp(`^${basePath || ''}\/w\/([^\/]*)`); + const urlObject = new URL(url); + const matchedResult = urlObject.pathname.match(regexp); + if (matchedResult) { + return matchedResult[1]; + } + + return ''; +}; + +export const cleanWorkspaceId = (path: string) => { + return path.replace(/^\/w\/([^\/]*)/, ''); +}; + +export const formatUrlWithWorkspaceId = (url: string, workspaceId: string, basePath: IBasePath) => { + const newUrl = new URL(url, window.location.href); + /** + * Patch workspace id into path + */ + newUrl.pathname = basePath.remove(newUrl.pathname); + + if (workspaceId) { + newUrl.pathname = `${WORKSPACE_PATH_PREFIX}/${workspaceId}${newUrl.pathname}`; + } else { + newUrl.pathname = cleanWorkspaceId(newUrl.pathname); + } + + newUrl.pathname = basePath.prepend(newUrl.pathname, { + withoutClientBasePath: true, + }); + + return newUrl.toString(); +}; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index dfd9d3d3e378..16916b9a41ad 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -856,7 +856,9 @@ exports[`dashboard listing hideWriteControls 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -1989,7 +1991,9 @@ exports[`dashboard listing render table listing with initial filters from URL 1` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -3183,7 +3187,9 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -4377,7 +4383,9 @@ exports[`dashboard listing renders table rows 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -5571,7 +5579,9 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 54b40858b4f1..5a6af05750c4 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -748,7 +748,9 @@ exports[`Dashboard top nav render in embed mode 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -1706,7 +1708,9 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -2664,7 +2668,9 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -3622,7 +3628,9 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -4580,7 +4588,9 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -5538,7 +5548,9 @@ exports[`Dashboard top nav render with all components 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap index 04120e429393..c2c83ff6f356 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -11,7 +11,9 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -378,7 +380,9 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", @@ -755,7 +759,9 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index d18762f4912f..1183c4cccd68 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -260,7 +260,9 @@ exports[`SavedObjectsTable should render normally 1`] = ` basePath={ BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index 14fe1fbabd88..d4a33e4a0569 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -168,7 +168,9 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 5d71bc774cff..2761ce16fea3 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -313,7 +313,9 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index e60bb6aea0eb..6ae89c0edad5 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,8 @@ * 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_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/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 40a7eb5c3f9f..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -2,10 +2,10 @@ "id": "workspace", "version": "opensearchDashboards", "server": true, - "ui": false, + "ui": true, "requiredPlugins": [ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..a6f496304889 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..594066e959f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render error with callout 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you want to go can not be found, try go back to home. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` render normally 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you want to go can not be found, try go back to home. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/index.ts b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts new file mode 100644 index 000000000000..afb34b10d913 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceFatalError } from './workspace_fatal_error'; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx new file mode 100644 index 000000000000..d98e0063dcfa --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceFatalError } from './workspace_fatal_error'; +import { context } from '../../../../opensearch_dashboards_react/public'; +import { coreMock } from '../../../../../core/public/mocks'; + +describe('', () => { + it('render normally', async () => { + const { findByText, container } = render( + + + + ); + await findByText('Something went wrong'); + expect(container).toMatchSnapshot(); + }); + + it('render error with callout', async () => { + const { findByText, container } = render( + + + + ); + await findByText('errorInCallout'); + expect(container).toMatchSnapshot(); + }); + + it('click go back to home', async () => { + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + const coreStartMock = coreMock.createStart(); + const { getByText } = render( + + + + + + ); + fireEvent.click(getByText('Go back to home')); + await waitFor( + () => { + expect(setHrefSpy).toBeCalledTimes(1); + }, + { + container: document.body, + } + ); + window.location = location; + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx new file mode 100644 index 000000000000..b1081e92237f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiCallOut, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { IBasePath } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; + +export function WorkspaceFatalError(props: { error?: string }) { + const { + services: { application, http }, + } = useOpenSearchDashboards(); + const goBackToHome = () => { + window.location.href = formatUrlWithWorkspaceId( + application?.getUrlForApp('home') || '', + '', + http?.basePath as IBasePath + ); + }; + return ( + + + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + ]} + /> + {props.error ? : null} +
+
+
+ ); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 8c869415aede..a0bf6b0f9704 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { waitFor } from '@testing-library/dom'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { Observable, Subscriber } from 'rxjs'; describe('Workspace plugin', () => { const getSetupMock = () => ({ @@ -25,9 +28,103 @@ describe('Workspace plugin', () => { it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); const coreStart = coreMock.createStart(); + workspacePlugin.setup(setupMock); workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); + }); + + it('#setup when workspace id is in url and enterWorkspace return error', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: false, + error: 'error', + }); + const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: applicationStartMock, + chrome: chromeStartMock, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); + windowSpy.mockRestore(); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 6f604bcf5678..8289351e0721 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,9 +4,20 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + AppNavLinkStatus, +} from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; @@ -19,9 +30,73 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } } + private getWorkspaceIdFromURL(basePath?: string): string | null { + return getWorkspaceIdFromUrl(window.location.href, basePath); + } public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); + + /** + * Retrieve workspace id from url + */ + const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); + + if (workspaceId) { + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } + } + + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 5ad4df46c919..f5b7da6430e0 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -15,12 +15,29 @@ import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; +import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; private client?: IWorkspaceClientImpl; private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; + private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { + /** + * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to {basePath}{osdPath*} + */ + setupDeps.http.registerOnPreRouting(async (request, response, toolkit) => { + const workspaceId = getWorkspaceIdFromUrl(request.url.toString()); + + if (workspaceId) { + const requestUrl = new URL(request.url.toString()); + requestUrl.pathname = cleanWorkspaceId(requestUrl.pathname); + return toolkit.rewriteUrl(requestUrl.toString()); + } + return toolkit.next(); + }); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); } @@ -39,6 +56,7 @@ export class WorkspacePlugin implements Plugin