From abfdb6b54eac88d48815f9ed6b42fd69da451226 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Oct 2023 17:45:28 +0800 Subject: [PATCH 01/17] [Workspace]Add workspace id in basePath (#212) * feat: enable workspace id in basePath Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: remove useless test object id Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: move formatUrlWithWorkspaceId to core/public/utils Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: update comment Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: add space under license Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../collapsible_nav.test.tsx.snap | 16 ++ .../header/__snapshots__/header.test.tsx.snap | 8 + src/core/public/http/base_path.test.ts | 32 +++ src/core/public/http/base_path.ts | 28 +- src/core/public/http/http_service.mock.ts | 10 +- src/core/public/http/http_service.test.ts | 26 ++ src/core/public/http/http_service.ts | 10 +- src/core/public/http/types.ts | 25 +- src/core/public/index.ts | 4 +- src/core/public/utils/index.ts | 6 + src/core/server/utils/index.ts | 1 + src/core/utils/constants.ts | 2 + src/core/utils/index.ts | 3 +- src/core/utils/workspace.test.ts | 32 +++ src/core/utils/workspace.ts | 42 +++ .../dashboard_listing.test.tsx.snap | 10 + .../dashboard_top_nav.test.tsx.snap | 12 + .../dashboard_empty_screen.test.tsx.snap | 6 + .../saved_objects_table.test.tsx.snap | 2 + .../__snapshots__/flyout.test.tsx.snap | 2 + ...telemetry_management_section.test.tsx.snap | 2 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 ++ .../workspace_fatal_error.test.tsx.snap | 180 ++++++++++++ .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.test.tsx | 71 +++++ .../workspace_fatal_error.tsx | 68 +++++ src/plugins/workspace/public/plugin.test.ts | 115 ++++++++ src/plugins/workspace/public/plugin.ts | 78 +++++- src/plugins/workspace/public/types.ts | 9 + .../workspace/public/workspace_client.mock.ts | 25 ++ .../workspace/public/workspace_client.test.ts | 181 ++++++++++++ .../workspace/public/workspace_client.ts | 262 ++++++++++++++++++ src/plugins/workspace/server/plugin.ts | 18 ++ 35 files changed, 1296 insertions(+), 26 deletions(-) create mode 100644 src/core/utils/workspace.test.ts create mode 100644 src/core/utils/workspace.ts create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx create mode 100644 src/plugins/workspace/public/plugin.test.ts create mode 100644 src/plugins/workspace/public/types.ts create mode 100644 src/plugins/workspace/public/workspace_client.mock.ts create mode 100644 src/plugins/workspace/public/workspace_client.test.ts create mode 100644 src/plugins/workspace/public/workspace_client.ts 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..f0cd8afddfa3 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 @@ -55,9 +55,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2006,9 +2008,11 @@ exports[`CollapsibleNav renders the default nav 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2308,9 +2312,11 @@ exports[`CollapsibleNav renders the default nav 2`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2611,9 +2617,11 @@ exports[`CollapsibleNav renders the default nav 3`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -3205,9 +3213,11 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -4320,9 +4330,11 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -5434,9 +5446,11 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -6541,9 +6555,11 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} 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..b763760e58d5 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 @@ -243,9 +243,11 @@ exports[`Header handles visibility and lock changes 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -5874,9 +5876,11 @@ exports[`Header handles visibility and lock changes 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} @@ -6935,9 +6939,11 @@ exports[`Header renders condensed header 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -11347,9 +11353,11 @@ exports[`Header renders condensed header 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 27cfa9bf0581..f80d41631b9b 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -110,4 +110,36 @@ describe('BasePath', () => { expect(new BasePath('/foo/bar', '/foo').serverBasePath).toEqual('/foo'); }); }); + + describe('workspaceBasePath', () => { + it('get path with workspace', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').get()).toEqual( + '/foo/bar/workspace' + ); + }); + + it('getBasePath with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').getBasePath()).toEqual('/foo/bar'); + }); + + it('prepend with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend')).toEqual( + '/foo/bar/workspace/prepend' + ); + }); + + it('prepend with workspace provided but calls without workspace', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend', { + withoutWorkspace: true, + }) + ).toEqual('/foo/bar/prepend'); + }); + + it('remove with workspace provided', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove') + ).toEqual('/remove'); + }); + }); }); diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index b31504676dba..254e4e2e6ad8 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 workspaceBasePath: string = '' ) {} public get = () => { + return `${this.basePath}${this.workspaceBasePath}`; + }; + + public getBasePath = () => { return this.basePath; }; - public prepend = (path: string): string => { - if (!this.basePath) return path; + public prepend = (path: string, prependOptions?: PrependOptions): string => { + const { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? 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 { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? 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..934e4cbc9394 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 = '', workspaceBasePath = '' } = {}): 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, workspaceBasePath), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), @@ -58,14 +58,14 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ intercept: jest.fn(), }); -const createMock = ({ basePath = '' } = {}) => { +const createMock = ({ basePath = '', workspaceBasePath = '' } = {}) => { 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, workspaceBasePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); 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..c2caf18be880 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 workspaceBasePath = ''; + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + } const basePath = new BasePath( injectedMetadata.getBasePath(), - injectedMetadata.getServerBasePath() + injectedMetadata.getServerBasePath(), + workspaceBasePath ); 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..709494963162 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -87,25 +87,40 @@ export interface HttpSetup { */ export type HttpStart = HttpSetup; +/** + * prepend options + * + * withoutWorkspace option will prepend a relative url with only basePath + * workspaceId will rewrite the /w/{workspaceId} part, if workspace id is an empty string, prepend will remove the workspaceId part + */ +export interface PrependOptions { + withoutWorkspace?: boolean; +} + /** * APIs for manipulating the basePath on URL segments. * @public */ export interface IBasePath { /** - * Gets the `basePath` string. + * Gets the `basePath + workspace` string. */ get: () => string; /** - * Prepends `path` with the basePath. + * Gets the `basePath + */ + getBasePath: () => string; + + /** + * Prepends `path` with the basePath + workspace. */ - prepend: (url: string) => string; + prepend: (url: string, prependOptions?: PrependOptions) => string; /** - * Removes the prepended basePath from the `path`. + * Removes the prepended basePath + workspace 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..7d2a1f700c5f --- /dev/null +++ b/src/core/utils/workspace.test.ts @@ -0,0 +1,32 @@ +/* + * 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(''); + }); +}); + +describe('#formatUrlWithWorkspaceId', () => { + const basePathWithoutWorkspaceBasePath = httpServiceMock.createSetupContract().basePath; + it('return url with workspace prefix when format with a id provided', () => { + expect( + formatUrlWithWorkspaceId('/app/dashboard', 'foo', basePathWithoutWorkspaceBasePath) + ).toEqual('http://localhost/w/foo/app/dashboard'); + }); + + it('return url without workspace prefix when format without a id', () => { + expect( + formatUrlWithWorkspaceId('/w/foo/app/dashboard', '', basePathWithoutWorkspaceBasePath) + ).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..c369f95d5817 --- /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): string => { + const regexp = /\/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, { + withoutWorkspace: 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 c9ffe147e5f8..73c4d4a4f2a3 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 @@ -857,9 +857,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1989,9 +1991,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3182,9 +3186,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4375,9 +4381,11 @@ exports[`dashboard listing renders table rows 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5568,9 +5576,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 1954051c9474..dabadcf65e38 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 @@ -749,9 +749,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1706,9 +1708,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2663,9 +2667,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3620,9 +3626,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4577,9 +4585,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5534,9 +5544,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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..187c24ba1528 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 @@ -12,9 +12,11 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -379,9 +381,11 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -756,9 +760,11 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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..fe237e8d8d0d 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 @@ -261,9 +261,11 @@ exports[`SavedObjectsTable should render normally 1`] = ` BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", } } canDelete={false} 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..7f47a6cef270 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 @@ -169,9 +169,11 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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..1576310d60e9 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 @@ -314,9 +314,11 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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..6a01faa2f76d 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "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 new file mode 100644 index 000000000000..370f60caab52 --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +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', () => { + beforeEach(() => { + WorkspaceClientMock.mockClear(); + Object.values(workspaceClientMock).forEach((item) => item.mockClear()); + }); + it('#setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + 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 = coreMock.createSetup(); + 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 = coreMock.createSetup(); + 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 18e84e3a6f35..5589299903d5 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,10 +3,82 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Plugin } from '../../../core/public'; +import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } 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 getWorkspaceIdFromURL(): string | null { + return getWorkspaceIdFromUrl(window.location.href); + } + 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(); + + 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); + }, + }); -export class WorkspacePlugin implements Plugin<{}, {}, {}> { - public async setup() { 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/public/workspace_client.mock.ts b/src/plugins/workspace/public/workspace_client.mock.ts new file mode 100644 index 000000000000..2ceeae5627d1 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const workspaceClientMock = { + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), +}; + +export const WorkspaceClientMock = jest.fn(function () { + return workspaceClientMock; +}); + +jest.doMock('./workspace_client', () => ({ + WorkspaceClient: WorkspaceClientMock, +})); diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts new file mode 100644 index 000000000000..7d05c3f22458 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; +import { WorkspaceClient } from './workspace_client'; + +const getWorkspaceClient = () => { + const httpSetupMock = httpServiceMock.createSetupContract(); + const workspaceMock = workspacesServiceMock.createSetupContract(); + return { + httpSetupMock, + workspaceMock, + workspaceClient: new WorkspaceClient(httpSetupMock, workspaceMock), + }; +}; + +describe('#WorkspaceClient', () => { + it('#init', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + await workspaceClient.init(); + expect(workspaceMock.initialized$.getValue()).toEqual(true); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#enterWorkspace', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: false, + }); + const result = await workspaceClient.enterWorkspace('foo'); + expect(result.success).toEqual(false); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + const successResult = await workspaceClient.enterWorkspace('foo'); + expect(workspaceMock.currentWorkspaceId$.getValue()).toEqual('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + expect(successResult.success).toEqual(true); + }); + + it('#getCurrentWorkspaceId', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspaceId()).toEqual({ + success: true, + result: 'foo', + }); + }); + + it('#getCurrentWorkspace', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + }, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspace()).toEqual({ + success: true, + result: { + name: 'foo', + }, + }); + }); + + it('#create', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create({ + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#delete', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.delete('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'DELETE', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#list', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.list({ + perPage: 999, + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#get', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + await workspaceClient.get('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + }); + + it('#update', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.update('foo', { + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); +}); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts new file mode 100644 index 000000000000..f9c219645d14 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.ts @@ -0,0 +1,262 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpFetchError, + HttpFetchOptions, + HttpSetup, + WorkspaceAttribute, + WorkspacesSetup, +} from '../../../core/public'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; + +interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; +} + +/** + * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to + * organize related features + * + * @public + */ +export class WorkspaceClient { + private http: HttpSetup; + private workspaces: WorkspacesSetup; + + constructor(http: HttpSetup, workspaces: WorkspacesSetup) { + this.http = http; + this.workspaces = workspaces; + } + + /** + * Initialize workspace list + */ + public async init() { + await this.updateWorkspaceList(); + this.workspaces.initialized$.next(true); + } + + /** + * Add a non-throw-error fetch method for internal use. + */ + private safeFetch = async ( + path: string, + options: HttpFetchOptions + ): Promise> => { + try { + return await this.http.fetch>(path, options); + } catch (error: unknown) { + if (error instanceof HttpFetchError) { + return { + success: false, + error: error.body?.message || error.body?.error || error.message, + }; + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: 'Unknown error', + }; + } + }; + + private getPath(...path: Array): string { + return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); + } + + private async updateWorkspaceList(): Promise { + const result = await this.list({ + perPage: 999, + }); + + if (result?.success) { + this.workspaces.workspaceList$.next(result.result.workspaces); + } + } + + public async enterWorkspace(id: string): Promise> { + const workspaceResp = await this.get(id); + if (workspaceResp.success) { + this.workspaces.currentWorkspaceId$.next(id); + return { + success: true, + result: null, + }; + } else { + return workspaceResp; + } + } + + public async getCurrentWorkspaceId(): Promise> { + const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); + if (!currentWorkspaceId) { + return { + success: false, + error: 'You are not in any workspace yet.', + }; + } + + return { + success: true, + result: currentWorkspaceId, + }; + } + + public async getCurrentWorkspace(): Promise> { + const currentWorkspaceIdResp = await this.getCurrentWorkspaceId(); + if (currentWorkspaceIdResp.success) { + const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); + return currentWorkspaceResp; + } else { + return currentWorkspaceIdResp; + } + } + + /** + * Persists an workspace + * + * @param attributes + * @returns + */ + public async create( + attributes: Omit + ): Promise> { + const path = this.getPath(); + + const result = await this.safeFetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + }), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Deletes a workspace + * + * @param id + * @returns + */ + public async delete(id: string): Promise> { + const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Search for workspaces + * + * @param {object} [options={}] + * @property {string} options.search + * @property {string} options.searchFields - see OpenSearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @returns A find result with workspaces matching the specified search. + */ + public list( + options?: WorkspaceFindOptions + ): Promise< + IResponse<{ + workspaces: WorkspaceAttribute[]; + total: number; + per_page: number; + page: number; + }> + > { + const path = this.getPath('_list'); + return this.safeFetch(path, { + method: 'POST', + body: JSON.stringify(options || {}), + }); + } + + /** + * Fetches a single workspace + * + * @param {string} id + * @returns The workspace for the given id. + */ + public get(id: string): Promise> { + const path = this.getPath(id); + return this.safeFetch(path, { + method: 'GET', + }); + } + + /** + * Updates a workspace + * + * @param {string} id + * @param {object} attributes + * @returns + */ + public async update( + id: string, + attributes: Partial + ): Promise> { + const path = this.getPath(id); + const body = { + attributes, + }; + + const result = await this.safeFetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + public stop() { + this.workspaces.workspaceList$.unsubscribe(); + this.workspaces.currentWorkspaceId$.unsubscribe(); + } +} diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index e4ed75bad615..6f126ebd5039 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<{}, {}> { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, this.workspaceConflictControl.wrapperFactory ); + this.proxyWorkspaceTrafficToRealHandler(core); registerRoutes({ http: core.http, From b6a1622c7e57ec184bcf3b554a4210a078a04ecd Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 7 Mar 2024 23:01:31 +0800 Subject: [PATCH 02/17] feat: add CHANGELOG Signed-off-by: SuZhou-Joe --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 979d6e02f204..a5475b58c1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Handles auth methods from auth registry in DataSource SavedObjects Client Wrapper ([#6062](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6062)) - [Multiple Datasource] Expose a few properties for customize the appearance of the data source selector component ([#6057](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6057)) - [Multiple Datasource] Create data source menu component able to be mount to nav bar ([#6082](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6082)) - +- [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) ### 🐛 Bug Fixes From 64b364545164ac3da2e43189ec060ad72f4a559b Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 8 Mar 2024 09:06:51 +0800 Subject: [PATCH 03/17] feat: add feature flag check Signed-off-by: SuZhou-Joe --- src/core/public/http/http_service.ts | 11 ++++++++--- src/plugins/workspace/opensearch_dashboards.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index c2caf18be880..0c9758b6525f 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -53,9 +53,14 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); let workspaceBasePath = ''; - const workspaceId = getWorkspaceIdFromUrl(window.location.href); - if (workspaceId) { - workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + const plugins = injectedMetadata.getPlugins(); + const findWorkspaceConfig = plugins.find((plugin) => plugin.id === 'workspace'); + // Only try to get workspace id from url when workspace feature is enabled + if (findWorkspaceConfig) { + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + } } const basePath = new BasePath( injectedMetadata.getBasePath(), diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 6a01faa2f76d..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -2,7 +2,7 @@ "id": "workspace", "version": "opensearchDashboards", "server": true, - "ui": false, + "ui": true, "requiredPlugins": [ "savedObjects" ], From 9b66a28d8bc1f927fcdd7b5dc7151b3f3712103d Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 8 Mar 2024 09:18:28 +0800 Subject: [PATCH 04/17] feat: make the pr smaller Signed-off-by: SuZhou-Joe --- src/plugins/workspace/common/constants.ts | 2 - .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 -- .../workspace_fatal_error.test.tsx.snap | 180 ------------ .../components/workspace_fatal_error/index.ts | 6 - .../workspace_fatal_error.test.tsx | 71 ----- .../workspace_fatal_error.tsx | 68 ----- src/plugins/workspace/public/plugin.test.ts | 92 +----- src/plugins/workspace/public/plugin.ts | 63 +---- .../workspace/public/workspace_client.mock.ts | 25 -- .../workspace/public/workspace_client.test.ts | 181 ------------ .../workspace/public/workspace_client.ts | 262 ------------------ 12 files changed, 10 insertions(+), 968 deletions(-) delete mode 100644 src/plugins/workspace/public/application.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap delete mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts delete mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx delete mode 100644 src/plugins/workspace/public/workspace_client.mock.ts delete mode 100644 src/plugins/workspace/public/workspace_client.test.ts delete mode 100644 src/plugins/workspace/public/workspace_client.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 6ae89c0edad5..e60bb6aea0eb 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,8 +3,6 @@ * 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 4443b7e99834..f34106ab4fed 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": ["opensearchDashboardsReact"] + "requiredBundles": [] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx deleted file mode 100644 index a6f496304889..000000000000 --- a/src/plugins/workspace/public/application.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 deleted file mode 100644 index 594066e959f7..000000000000 --- a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap +++ /dev/null @@ -1,180 +0,0 @@ -// 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 deleted file mode 100644 index afb34b10d913..000000000000 --- a/src/plugins/workspace/public/components/workspace_fatal_error/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * 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 deleted file mode 100644 index d98e0063dcfa..000000000000 --- a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 deleted file mode 100644 index b1081e92237f..000000000000 --- a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 370f60caab52..d2e82b6d37f8 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,25 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { waitFor } from '@testing-library/dom'; -import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { 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', () => { - beforeEach(() => { - WorkspaceClientMock.mockClear(); - Object.values(workspaceClientMock).forEach((item) => item.mockClear()); + const getSetupMock = () => ({ + ...coreMock.createSetup(), + chrome: chromeServiceMock.createSetupContract(), }); it('#setup', async () => { - const setupMock = coreMock.createSetup(); + const setupMock = getSetupMock(); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); - 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 () => { @@ -34,82 +27,11 @@ describe('Workspace plugin', () => { }, } as any) ); - workspaceClientMock.enterWorkspace.mockResolvedValue({ - success: false, - error: 'error', - }); - const setupMock = coreMock.createSetup(); - const applicationStartMock = applicationServiceMock.createStartContract(); - const chromeStartMock = chromeServiceMock.createStartContract(); - setupMock.getStartServices.mockImplementation(() => { - return Promise.resolve([ - { - application: applicationStartMock, - chrome: chromeStartMock, - }, - {}, - {}, - ]) as any; - }); + const setupMock = getSetupMock(); 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 = coreMock.createSetup(); - 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); + expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); windowSpy.mockRestore(); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5589299903d5..b4c154e01e96 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,82 +3,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } from '../../../core/public'; -import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { CoreSetup, Plugin } from '../../../core/public'; 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 getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } 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(); 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(); - }); - })(); - } + core.workspaces.currentWorkspaceId$.next(workspaceId); } - 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/workspace_client.mock.ts b/src/plugins/workspace/public/workspace_client.mock.ts deleted file mode 100644 index 2ceeae5627d1..000000000000 --- a/src/plugins/workspace/public/workspace_client.mock.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export const workspaceClientMock = { - init: jest.fn(), - enterWorkspace: jest.fn(), - getCurrentWorkspaceId: jest.fn(), - getCurrentWorkspace: jest.fn(), - create: jest.fn(), - delete: jest.fn(), - list: jest.fn(), - get: jest.fn(), - update: jest.fn(), - stop: jest.fn(), -}; - -export const WorkspaceClientMock = jest.fn(function () { - return workspaceClientMock; -}); - -jest.doMock('./workspace_client', () => ({ - WorkspaceClient: WorkspaceClientMock, -})); diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts deleted file mode 100644 index 7d05c3f22458..000000000000 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; -import { WorkspaceClient } from './workspace_client'; - -const getWorkspaceClient = () => { - const httpSetupMock = httpServiceMock.createSetupContract(); - const workspaceMock = workspacesServiceMock.createSetupContract(); - return { - httpSetupMock, - workspaceMock, - workspaceClient: new WorkspaceClient(httpSetupMock, workspaceMock), - }; -}; - -describe('#WorkspaceClient', () => { - it('#init', async () => { - const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); - await workspaceClient.init(); - expect(workspaceMock.initialized$.getValue()).toEqual(true); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { - method: 'POST', - body: JSON.stringify({ - perPage: 999, - }), - }); - }); - - it('#enterWorkspace', async () => { - const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: false, - }); - const result = await workspaceClient.enterWorkspace('foo'); - expect(result.success).toEqual(false); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - }); - const successResult = await workspaceClient.enterWorkspace('foo'); - expect(workspaceMock.currentWorkspaceId$.getValue()).toEqual('foo'); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { - method: 'GET', - }); - expect(successResult.success).toEqual(true); - }); - - it('#getCurrentWorkspaceId', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - }); - await workspaceClient.enterWorkspace('foo'); - expect(await workspaceClient.getCurrentWorkspaceId()).toEqual({ - success: true, - result: 'foo', - }); - }); - - it('#getCurrentWorkspace', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - result: { - name: 'foo', - }, - }); - await workspaceClient.enterWorkspace('foo'); - expect(await workspaceClient.getCurrentWorkspace()).toEqual({ - success: true, - result: { - name: 'foo', - }, - }); - }); - - it('#create', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - result: { - name: 'foo', - workspaces: [], - }, - }); - await workspaceClient.create({ - name: 'foo', - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { - method: 'POST', - body: JSON.stringify({ - attributes: { - name: 'foo', - }, - }), - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { - method: 'POST', - body: JSON.stringify({ - perPage: 999, - }), - }); - }); - - it('#delete', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - result: { - name: 'foo', - workspaces: [], - }, - }); - await workspaceClient.delete('foo'); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { - method: 'DELETE', - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { - method: 'POST', - body: JSON.stringify({ - perPage: 999, - }), - }); - }); - - it('#list', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - result: { - workspaces: [], - }, - }); - await workspaceClient.list({ - perPage: 999, - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { - method: 'POST', - body: JSON.stringify({ - perPage: 999, - }), - }); - }); - - it('#get', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - await workspaceClient.get('foo'); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { - method: 'GET', - }); - }); - - it('#update', async () => { - const { workspaceClient, httpSetupMock } = getWorkspaceClient(); - httpSetupMock.fetch.mockResolvedValue({ - success: true, - result: { - workspaces: [], - }, - }); - await workspaceClient.update('foo', { - name: 'foo', - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { - method: 'PUT', - body: JSON.stringify({ - attributes: { - name: 'foo', - }, - }), - }); - expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { - method: 'POST', - body: JSON.stringify({ - perPage: 999, - }), - }); - }); -}); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts deleted file mode 100644 index f9c219645d14..000000000000 --- a/src/plugins/workspace/public/workspace_client.ts +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - HttpFetchError, - HttpFetchOptions, - HttpSetup, - WorkspaceAttribute, - WorkspacesSetup, -} from '../../../core/public'; - -const WORKSPACES_API_BASE_URL = '/api/workspaces'; - -const join = (...uriComponents: Array) => - uriComponents - .filter((comp): comp is string => Boolean(comp)) - .map(encodeURIComponent) - .join('/'); - -type IResponse = - | { - result: T; - success: true; - } - | { - success: false; - error?: string; - }; - -interface WorkspaceFindOptions { - page?: number; - perPage?: number; - search?: string; - searchFields?: string[]; - sortField?: string; - sortOrder?: string; -} - -/** - * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to - * organize related features - * - * @public - */ -export class WorkspaceClient { - private http: HttpSetup; - private workspaces: WorkspacesSetup; - - constructor(http: HttpSetup, workspaces: WorkspacesSetup) { - this.http = http; - this.workspaces = workspaces; - } - - /** - * Initialize workspace list - */ - public async init() { - await this.updateWorkspaceList(); - this.workspaces.initialized$.next(true); - } - - /** - * Add a non-throw-error fetch method for internal use. - */ - private safeFetch = async ( - path: string, - options: HttpFetchOptions - ): Promise> => { - try { - return await this.http.fetch>(path, options); - } catch (error: unknown) { - if (error instanceof HttpFetchError) { - return { - success: false, - error: error.body?.message || error.body?.error || error.message, - }; - } - - if (error instanceof Error) { - return { - success: false, - error: error.message, - }; - } - - return { - success: false, - error: 'Unknown error', - }; - } - }; - - private getPath(...path: Array): string { - return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); - } - - private async updateWorkspaceList(): Promise { - const result = await this.list({ - perPage: 999, - }); - - if (result?.success) { - this.workspaces.workspaceList$.next(result.result.workspaces); - } - } - - public async enterWorkspace(id: string): Promise> { - const workspaceResp = await this.get(id); - if (workspaceResp.success) { - this.workspaces.currentWorkspaceId$.next(id); - return { - success: true, - result: null, - }; - } else { - return workspaceResp; - } - } - - public async getCurrentWorkspaceId(): Promise> { - const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); - if (!currentWorkspaceId) { - return { - success: false, - error: 'You are not in any workspace yet.', - }; - } - - return { - success: true, - result: currentWorkspaceId, - }; - } - - public async getCurrentWorkspace(): Promise> { - const currentWorkspaceIdResp = await this.getCurrentWorkspaceId(); - if (currentWorkspaceIdResp.success) { - const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); - return currentWorkspaceResp; - } else { - return currentWorkspaceIdResp; - } - } - - /** - * Persists an workspace - * - * @param attributes - * @returns - */ - public async create( - attributes: Omit - ): Promise> { - const path = this.getPath(); - - const result = await this.safeFetch(path, { - method: 'POST', - body: JSON.stringify({ - attributes, - }), - }); - - if (result.success) { - await this.updateWorkspaceList(); - } - - return result; - } - - /** - * Deletes a workspace - * - * @param id - * @returns - */ - public async delete(id: string): Promise> { - const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); - - if (result.success) { - await this.updateWorkspaceList(); - } - - return result; - } - - /** - * Search for workspaces - * - * @param {object} [options={}] - * @property {string} options.search - * @property {string} options.searchFields - see OpenSearch Simple Query String - * Query field argument for more information - * @property {integer} [options.page=1] - * @property {integer} [options.perPage=20] - * @property {array} options.fields - * @returns A find result with workspaces matching the specified search. - */ - public list( - options?: WorkspaceFindOptions - ): Promise< - IResponse<{ - workspaces: WorkspaceAttribute[]; - total: number; - per_page: number; - page: number; - }> - > { - const path = this.getPath('_list'); - return this.safeFetch(path, { - method: 'POST', - body: JSON.stringify(options || {}), - }); - } - - /** - * Fetches a single workspace - * - * @param {string} id - * @returns The workspace for the given id. - */ - public get(id: string): Promise> { - const path = this.getPath(id); - return this.safeFetch(path, { - method: 'GET', - }); - } - - /** - * Updates a workspace - * - * @param {string} id - * @param {object} attributes - * @returns - */ - public async update( - id: string, - attributes: Partial - ): Promise> { - const path = this.getPath(id); - const body = { - attributes, - }; - - const result = await this.safeFetch(path, { - method: 'PUT', - body: JSON.stringify(body), - }); - - if (result.success) { - await this.updateWorkspaceList(); - } - - return result; - } - - public stop() { - this.workspaces.workspaceList$.unsubscribe(); - this.workspaces.currentWorkspaceId$.unsubscribe(); - } -} From 2e388a57f3d4192f1c06b90cb019d2adefa8cc1a Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 8 Mar 2024 09:41:00 +0800 Subject: [PATCH 05/17] feat: optimize with a more strict check Signed-off-by: SuZhou-Joe --- src/core/public/http/http_service.ts | 5 ++++- src/core/utils/workspace.test.ts | 8 ++++++++ src/core/utils/workspace.ts | 4 ++-- src/plugins/workspace/public/plugin.ts | 6 +++--- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 0c9758b6525f..f0b988f5d955 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -57,7 +57,10 @@ export class HttpService implements CoreService { const findWorkspaceConfig = plugins.find((plugin) => plugin.id === 'workspace'); // Only try to get workspace id from url when workspace feature is enabled if (findWorkspaceConfig) { - const workspaceId = getWorkspaceIdFromUrl(window.location.href); + const workspaceId = getWorkspaceIdFromUrl( + window.location.href, + injectedMetadata.getBasePath() + ); if (workspaceId) { workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; } diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts index 7d2a1f700c5f..a10d0549ce61 100644 --- a/src/core/utils/workspace.test.ts +++ b/src/core/utils/workspace.test.ts @@ -14,6 +14,14 @@ describe('#getWorkspaceIdFromUrl', () => { 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', () => { diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts index c369f95d5817..1d49338f5072 100644 --- a/src/core/utils/workspace.ts +++ b/src/core/utils/workspace.ts @@ -6,8 +6,8 @@ import { WORKSPACE_PATH_PREFIX } from './constants'; import { IBasePath } from '../public'; -export const getWorkspaceIdFromUrl = (url: string): string => { - const regexp = /\/w\/([^\/]*)/; +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) { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index b4c154e01e96..c93e592acf63 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -7,14 +7,14 @@ import { CoreSetup, Plugin } from '../../../core/public'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; export class WorkspacePlugin implements Plugin<{}, {}> { - private getWorkspaceIdFromURL(): string | null { - return getWorkspaceIdFromUrl(window.location.href); + private getWorkspaceIdFromURL(basePath?: string): string | null { + return getWorkspaceIdFromUrl(window.location.href, basePath); } public async setup(core: CoreSetup) { /** * Retrieve workspace id from url */ - const workspaceId = this.getWorkspaceIdFromURL(); + const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); if (workspaceId) { core.workspaces.currentWorkspaceId$.next(workspaceId); From 80bed72a6c66dacde6f9f1591f7c348a0a5aeb4f Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 8 Mar 2024 09:54:39 +0800 Subject: [PATCH 06/17] fix: unit test error Signed-off-by: SuZhou-Joe --- src/core/public/http/http_service.test.ts | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index 5671064e4c52..c6301b26d1d1 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -83,7 +83,7 @@ describe('#setup()', () => { expect(setupResult.basePath.get()).toEqual(''); }); - it('setup basePath with workspaceId provided in window.location.href', () => { + it('setup basePath with workspaceId provided in window.location.href and workspace feature enabled', () => { const windowSpy = jest.spyOn(window, 'window', 'get'); windowSpy.mockImplementation( () => @@ -94,12 +94,43 @@ describe('#setup()', () => { } as any) ); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getPlugins.mockReturnValueOnce([ + { + id: 'workspace', + plugin: { + id: 'workspace', + configPath: '', + requiredPlugins: [], + optionalPlugins: [], + requiredEnginePlugins: {}, + requiredBundles: [], + }, + }, + ]); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); const httpService = new HttpService(); const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); windowSpy.mockRestore(); }); + + it('setup basePath with workspaceId provided in window.location.href but workspace feature disabled', () => { + 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(''); + windowSpy.mockRestore(); + }); }); describe('#stop()', () => { From 2d710b8520e933c1b1b563fa9e8539002b2803db Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 8 Mar 2024 11:39:01 +0800 Subject: [PATCH 07/17] feat: remove useless code Signed-off-by: SuZhou-Joe --- src/plugins/workspace/public/types.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/plugins/workspace/public/types.ts diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts deleted file mode 100644 index 1b3f38e50857..000000000000 --- a/src/plugins/workspace/public/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * 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 }; From 2a1148ee5a980d9e2badbcd5aeb8a221724ac6dd Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Sat, 9 Mar 2024 00:46:10 +0800 Subject: [PATCH 08/17] feat: add a unit test case Signed-off-by: SuZhou-Joe --- src/core/public/http/base_path.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index f80d41631b9b..e541c1c8c6de 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -141,5 +141,13 @@ describe('BasePath', () => { new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove') ).toEqual('/remove'); }); + + it('remove with workspace provided but calls without workspace', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove', { + withoutWorkspace: true, + }) + ).toEqual('/workspace/remove'); + }); }); }); From 50648b6837a468b989bffa6a3d6b3afc10b43b17 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Sat, 9 Mar 2024 09:56:22 +0800 Subject: [PATCH 09/17] feat: better merge Signed-off-by: SuZhou-Joe --- src/plugins/workspace/public/plugin.test.ts | 2 +- src/plugins/workspace/public/plugin.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index f2fac5f6e1eb..95d8de4613b3 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { coreMock, chromeServiceMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; describe('Workspace plugin', () => { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 235885c0f10f..ee9e0a3a924b 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,10 +4,10 @@ */ import type { Subscription } from 'rxjs'; -import { CoreSetup, Plugin, CoreStart } from '../../../core/public'; +import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; -export class WorkspacePlugin implements Plugin<{}, {}> { +export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; private _changeSavedObjectCurrentWorkspace() { From e290a13300666e794a456c05df1bb29cf2804b0c Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 11 Mar 2024 18:14:22 +0800 Subject: [PATCH 10/17] feat: rename the workspaceBasePath to clientBasePath Signed-off-by: SuZhou-Joe --- .../__snapshots__/collapsible_nav.test.tsx.snap | 16 ++++++++-------- .../ui/header/__snapshots__/header.test.tsx.snap | 8 ++++---- src/core/public/http/base_path.test.ts | 2 +- src/core/public/http/base_path.ts | 4 ++-- src/core/public/http/http_service.mock.ts | 10 +++++----- src/core/public/http/http_service.ts | 6 +++--- src/core/utils/workspace.test.ts | 6 +++--- .../dashboard_listing.test.tsx.snap | 10 +++++----- .../dashboard_top_nav.test.tsx.snap | 12 ++++++------ .../dashboard_empty_screen.test.tsx.snap | 6 +++--- .../saved_objects_table.test.tsx.snap | 2 +- .../__snapshots__/flyout.test.tsx.snap | 2 +- .../telemetry_management_section.test.tsx.snap | 2 +- 13 files changed, 43 insertions(+), 43 deletions(-) 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 f0cd8afddfa3..151004b2bfc6 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 @@ -59,7 +59,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} @@ -2012,7 +2012,7 @@ exports[`CollapsibleNav renders the default nav 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} @@ -2316,7 +2316,7 @@ exports[`CollapsibleNav renders the default nav 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} @@ -2621,7 +2621,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} @@ -3217,7 +3217,7 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={ @@ -4334,7 +4334,7 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={ @@ -5450,7 +5450,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={ @@ -6559,7 +6559,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} 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 b763760e58d5..6aa25a794bea 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 @@ -247,7 +247,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={Object {}} @@ -5880,7 +5880,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } closeNav={[Function]} @@ -6943,7 +6943,7 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } branding={ @@ -11357,7 +11357,7 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", + "clientBasePath": "", } } closeNav={[Function]} diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index e541c1c8c6de..ac979205b8d1 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -111,7 +111,7 @@ describe('BasePath', () => { }); }); - describe('workspaceBasePath', () => { + describe('clientBasePath', () => { it('get path with workspace', () => { expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').get()).toEqual( '/foo/bar/workspace' diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index 254e4e2e6ad8..5849f8e7aadc 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -35,11 +35,11 @@ export class BasePath { constructor( private readonly basePath: string = '', public readonly serverBasePath: string = basePath, - private readonly workspaceBasePath: string = '' + private readonly clientBasePath: string = '' ) {} public get = () => { - return `${this.basePath}${this.workspaceBasePath}`; + return `${this.basePath}${this.clientBasePath}`; }; public getBasePath = () => { diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 934e4cbc9394..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 = '', workspaceBasePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ basePath = '', clientBasePath = '' } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -48,7 +48,7 @@ const createServiceMock = ({ basePath = '', workspaceBasePath = '' } = {}): Http patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath, undefined, workspaceBasePath), + basePath: new BasePath(basePath, undefined, clientBasePath), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), @@ -58,14 +58,14 @@ const createServiceMock = ({ basePath = '', workspaceBasePath = '' } = {}): Http intercept: jest.fn(), }); -const createMock = ({ basePath = '', workspaceBasePath = '' } = {}) => { +const createMock = ({ basePath = '', clientBasePath = '' } = {}) => { const mocked: jest.Mocked> = { setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); - mocked.start.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); + mocked.setup.mockReturnValue(createServiceMock({ basePath, clientBasePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath, clientBasePath })); return mocked; }; diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index f0b988f5d955..e0faa05d07b3 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -52,7 +52,7 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); - let workspaceBasePath = ''; + let clientBasePath = ''; const plugins = injectedMetadata.getPlugins(); const findWorkspaceConfig = plugins.find((plugin) => plugin.id === 'workspace'); // Only try to get workspace id from url when workspace feature is enabled @@ -62,13 +62,13 @@ export class HttpService implements CoreService { injectedMetadata.getBasePath() ); if (workspaceId) { - workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + clientBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; } } const basePath = new BasePath( injectedMetadata.getBasePath(), injectedMetadata.getServerBasePath(), - workspaceBasePath + clientBasePath ); const fetchService = new Fetch({ basePath, opensearchDashboardsVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts index a10d0549ce61..a852ddcc5190 100644 --- a/src/core/utils/workspace.test.ts +++ b/src/core/utils/workspace.test.ts @@ -25,16 +25,16 @@ describe('#getWorkspaceIdFromUrl', () => { }); describe('#formatUrlWithWorkspaceId', () => { - const basePathWithoutWorkspaceBasePath = httpServiceMock.createSetupContract().basePath; + const basePathWithoutClientBasePath = httpServiceMock.createSetupContract().basePath; it('return url with workspace prefix when format with a id provided', () => { expect( - formatUrlWithWorkspaceId('/app/dashboard', 'foo', basePathWithoutWorkspaceBasePath) + 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', '', basePathWithoutWorkspaceBasePath) + formatUrlWithWorkspaceId('/w/foo/app/dashboard', '', basePathWithoutClientBasePath) ).toEqual('http://localhost/app/dashboard'); }); }); 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 2102d4cbc6bf..930b8eca265e 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 @@ -861,7 +861,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1996,7 +1996,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3192,7 +3192,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4388,7 +4388,7 @@ exports[`dashboard listing renders table rows 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5584,7 +5584,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 414ec142374a..cb858689bd11 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 @@ -753,7 +753,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1713,7 +1713,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2673,7 +2673,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3633,7 +3633,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4593,7 +4593,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5553,7 +5553,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 187c24ba1528..2e23e1e91d3b 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 @@ -16,7 +16,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -385,7 +385,7 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -764,7 +764,7 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 fe237e8d8d0d..75882d4ebc18 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 @@ -265,7 +265,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", } } canDelete={false} 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 7f47a6cef270..ff8009e90ce0 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 @@ -173,7 +173,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 1576310d60e9..34d29ddf73c0 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 @@ -318,7 +318,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", + "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], From 6cc5e09f01d523a7c53ac0954787226e05c41de2 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 10:28:00 +0800 Subject: [PATCH 11/17] fix: snapshot Signed-off-by: SuZhou-Joe --- .../__snapshots__/collapsible_nav.test.tsx.snap | 16 ++++++++-------- .../ui/header/__snapshots__/header.test.tsx.snap | 8 ++++---- .../dashboard_listing.test.tsx.snap | 10 +++++----- .../dashboard_top_nav.test.tsx.snap | 12 ++++++------ .../dashboard_empty_screen.test.tsx.snap | 6 +++--- .../saved_objects_table.test.tsx.snap | 2 +- .../__snapshots__/flyout.test.tsx.snap | 2 +- .../telemetry_management_section.test.tsx.snap | 2 +- 8 files changed, 29 insertions(+), 29 deletions(-) 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 151004b2bfc6..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,12 +54,12 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={Object {}} @@ -2007,12 +2007,12 @@ exports[`CollapsibleNav renders the default nav 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={Object {}} @@ -2311,12 +2311,12 @@ exports[`CollapsibleNav renders the default nav 2`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={Object {}} @@ -2616,12 +2616,12 @@ exports[`CollapsibleNav renders the default nav 3`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={Object {}} @@ -3212,12 +3212,12 @@ 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", - "clientBasePath": "", } } branding={ @@ -4329,12 +4329,12 @@ 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", - "clientBasePath": "", } } branding={ @@ -5445,12 +5445,12 @@ 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", - "clientBasePath": "", } } branding={ @@ -6554,12 +6554,12 @@ 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", - "clientBasePath": "", } } branding={Object {}} 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 6aa25a794bea..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,12 +242,12 @@ exports[`Header handles visibility and lock changes 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={Object {}} @@ -5875,12 +5875,12 @@ exports[`Header handles visibility and lock changes 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } closeNav={[Function]} @@ -6938,12 +6938,12 @@ exports[`Header renders condensed header 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } branding={ @@ -11352,12 +11352,12 @@ exports[`Header renders condensed header 1`] = ` basePath={ BasePath { "basePath": "/test", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "clientBasePath": "", } } closeNav={[Function]} 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 930b8eca265e..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,12 +856,12 @@ exports[`dashboard listing hideWriteControls 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1991,12 +1991,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3187,12 +3187,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4383,12 +4383,12 @@ exports[`dashboard listing renders table rows 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5579,12 +5579,12 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 cb858689bd11..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,12 +748,12 @@ exports[`Dashboard top nav render in embed mode 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1708,12 +1708,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2668,12 +2668,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3628,12 +3628,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4588,12 +4588,12 @@ 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": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5548,12 +5548,12 @@ exports[`Dashboard top nav render with all components 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 2e23e1e91d3b..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,12 +11,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -380,12 +380,12 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -759,12 +759,12 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 75882d4ebc18..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,12 +260,12 @@ exports[`SavedObjectsTable should render normally 1`] = ` basePath={ BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", } } canDelete={false} 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 ff8009e90ce0..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,12 +168,12 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 34d29ddf73c0..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,12 +313,12 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO }, "basePath": BasePath { "basePath": "", + "clientBasePath": "", "get": [Function], "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", - "clientBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], From 367268d2c2e96c7fdf0c8a1c12ada07ec94de36d Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 14:34:50 +0800 Subject: [PATCH 12/17] feat: rename withoutWorkspace to withoutClientBasePath Signed-off-by: SuZhou-Joe --- src/core/public/http/base_path.test.ts | 8 ++++---- src/core/public/http/base_path.ts | 8 ++++---- src/core/public/http/types.ts | 9 ++++++--- src/core/utils/workspace.ts | 2 +- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index ac979205b8d1..11e897b2f598 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -128,10 +128,10 @@ describe('BasePath', () => { ); }); - it('prepend with workspace provided but calls without workspace', () => { + it('prepend with client base path provided but calls without client base path', () => { expect( new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend', { - withoutWorkspace: true, + withoutClientBasePath: true, }) ).toEqual('/foo/bar/prepend'); }); @@ -142,10 +142,10 @@ describe('BasePath', () => { ).toEqual('/remove'); }); - it('remove with workspace provided but calls without workspace', () => { + it('remove with client base path provided but calls without client base path', () => { expect( new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove', { - withoutWorkspace: true, + withoutClientBasePath: true, }) ).toEqual('/workspace/remove'); }); diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index 5849f8e7aadc..c88602c35b9d 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -47,8 +47,8 @@ export class BasePath { }; public prepend = (path: string, prependOptions?: PrependOptions): string => { - const { withoutWorkspace } = prependOptions || {}; - const basePath = withoutWorkspace ? this.basePath : this.get(); + 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('/')) { @@ -58,8 +58,8 @@ export class BasePath { }; public remove = (path: string, prependOptions?: PrependOptions): string => { - const { withoutWorkspace } = prependOptions || {}; - const basePath = withoutWorkspace ? this.basePath : this.get(); + const { withoutClientBasePath } = prependOptions || {}; + const basePath = withoutClientBasePath ? this.basePath : this.get(); if (!basePath) { return path; } diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 709494963162..655f7516fdae 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -90,11 +90,14 @@ export type HttpStart = HttpSetup; /** * prepend options * - * withoutWorkspace option will prepend a relative url with only basePath - * workspaceId will rewrite the /w/{workspaceId} part, if workspace id is an empty string, prepend will remove the workspaceId part + * withoutClientBasePath option will prepend a relative url with serverBasePath only. + * For now, clientBasePath is consist of: + * workspacePath, which has the pattern of /w/{workspaceId}. + * + * clientBasePath may have more 1 part in the future but keep `withoutClientBasePath` for now to not over-design the interface, */ export interface PrependOptions { - withoutWorkspace?: boolean; + withoutClientBasePath?: boolean; } /** diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts index 1d49338f5072..c383967483a8 100644 --- a/src/core/utils/workspace.ts +++ b/src/core/utils/workspace.ts @@ -35,7 +35,7 @@ export const formatUrlWithWorkspaceId = (url: string, workspaceId: string, baseP } newUrl.pathname = basePath.prepend(newUrl.pathname, { - withoutWorkspace: true, + withoutClientBasePath: true, }); return newUrl.toString(); From a5fd308add14113abbab2ad96b38dec7fc6cc5b0 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 14:36:58 +0800 Subject: [PATCH 13/17] Revert "feat: add feature flag check" This reverts commit 64b364545164ac3da2e43189ec060ad72f4a559b. Signed-off-by: SuZhou-Joe --- src/core/public/http/http_service.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index e0faa05d07b3..6832703c7925 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -53,17 +53,9 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); let clientBasePath = ''; - const plugins = injectedMetadata.getPlugins(); - const findWorkspaceConfig = plugins.find((plugin) => plugin.id === 'workspace'); - // Only try to get workspace id from url when workspace feature is enabled - if (findWorkspaceConfig) { - const workspaceId = getWorkspaceIdFromUrl( - window.location.href, - injectedMetadata.getBasePath() - ); - if (workspaceId) { - clientBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; - } + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + clientBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; } const basePath = new BasePath( injectedMetadata.getBasePath(), From 3980cd74688fad303727c3bbfe4e5732d40742d4 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 14:38:04 +0800 Subject: [PATCH 14/17] Revert "fix: unit test error" This reverts commit 80bed72a6c66dacde6f9f1591f7c348a0a5aeb4f. Signed-off-by: SuZhou-Joe --- src/core/public/http/http_service.test.ts | 33 +---------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index c6301b26d1d1..5671064e4c52 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -83,7 +83,7 @@ describe('#setup()', () => { expect(setupResult.basePath.get()).toEqual(''); }); - it('setup basePath with workspaceId provided in window.location.href and workspace feature enabled', () => { + it('setup basePath with workspaceId provided in window.location.href', () => { const windowSpy = jest.spyOn(window, 'window', 'get'); windowSpy.mockImplementation( () => @@ -94,43 +94,12 @@ describe('#setup()', () => { } as any) ); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); - injectedMetadata.getPlugins.mockReturnValueOnce([ - { - id: 'workspace', - plugin: { - id: 'workspace', - configPath: '', - requiredPlugins: [], - optionalPlugins: [], - requiredEnginePlugins: {}, - requiredBundles: [], - }, - }, - ]); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); const httpService = new HttpService(); const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); windowSpy.mockRestore(); }); - - it('setup basePath with workspaceId provided in window.location.href but workspace feature disabled', () => { - 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(''); - windowSpy.mockRestore(); - }); }); describe('#stop()', () => { From d544f944a310239ec56537d111792d80af570ffc Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 14:58:48 +0800 Subject: [PATCH 15/17] feat: optimize comment and test cases description Signed-off-by: SuZhou-Joe --- src/core/public/http/base_path.test.ts | 41 +++++++++++++++----------- src/core/public/http/types.ts | 10 +++---- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 11e897b2f598..0ee1f08732ed 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -112,42 +112,49 @@ describe('BasePath', () => { }); describe('clientBasePath', () => { - it('get path with workspace', () => { - expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').get()).toEqual( - '/foo/bar/workspace' + it('get path with clientBasePath', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').get()).toEqual( + '/foo/bar/client_base_path' ); }); - it('getBasePath with workspace provided', () => { - expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').getBasePath()).toEqual('/foo/bar'); + it('getBasePath with clientBasePath provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').getBasePath()).toEqual( + '/foo/bar' + ); }); - it('prepend with workspace provided', () => { - expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend')).toEqual( - '/foo/bar/workspace/prepend' + it('prepend with clientBasePath provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/client_base_path').prepend('/prepend')).toEqual( + '/foo/bar/client_base_path/prepend' ); }); - it('prepend with client base path provided but calls without client base path', () => { + it('prepend with clientBasePath provided but calls withoutClientBasePath', () => { expect( - new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend', { + new BasePath('/foo/bar', '/foo/bar', '/client_base_path').prepend('/prepend', { withoutClientBasePath: true, }) ).toEqual('/foo/bar/prepend'); }); - it('remove with workspace provided', () => { + it('remove with clientBasePath provided', () => { expect( - new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove') + new BasePath('/foo/bar', '/foo/bar', '/client_base_path').remove( + '/foo/bar/client_base_path/remove' + ) ).toEqual('/remove'); }); - it('remove with client base path provided but calls without client base path', () => { + it('remove with clientBasePath provided but calls withoutClientBasePath', () => { expect( - new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove', { - withoutClientBasePath: true, - }) - ).toEqual('/workspace/remove'); + 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/types.ts b/src/core/public/http/types.ts index 655f7516fdae..6e93e1cee94a 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -94,7 +94,7 @@ export type HttpStart = HttpSetup; * For now, clientBasePath is consist of: * workspacePath, which has the pattern of /w/{workspaceId}. * - * clientBasePath may have more 1 part in the future but keep `withoutClientBasePath` for now to not over-design the interface, + * In the future, clientBasePath may have other parts but keep `withoutClientBasePath` for now to not over-design the interface, */ export interface PrependOptions { withoutClientBasePath?: boolean; @@ -106,22 +106,22 @@ export interface PrependOptions { */ export interface IBasePath { /** - * Gets the `basePath + workspace` string. + * Gets the `basePath + clientBasePath` string. */ get: () => string; /** - * Gets the `basePath + * Gets the `basePath` string */ getBasePath: () => string; /** - * Prepends `path` with the basePath + workspace. + * Prepends `path` with the basePath + clientBasePath. */ prepend: (url: string, prependOptions?: PrependOptions) => string; /** - * Removes the prepended basePath + workspace from the `path`. + * Removes the prepended basePath + clientBasePath from the `path`. */ remove: (url: string, prependOptions?: PrependOptions) => string; From d4f716c362273c59513de48b859b6752da4c7b81 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 12 Mar 2024 15:04:34 +0800 Subject: [PATCH 16/17] feat: optimize comment Signed-off-by: SuZhou-Joe --- src/core/public/http/base_path.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 0ee1f08732ed..921ec13e6db2 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -112,25 +112,25 @@ describe('BasePath', () => { }); describe('clientBasePath', () => { - it('get path with 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', () => { + 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', () => { + 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('prepend with clientBasePath provided but calls withoutClientBasePath', () => { + 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, @@ -138,7 +138,7 @@ describe('BasePath', () => { ).toEqual('/foo/bar/prepend'); }); - it('remove with clientBasePath provided', () => { + it('remove with clientBasePath provided when construct', () => { expect( new BasePath('/foo/bar', '/foo/bar', '/client_base_path').remove( '/foo/bar/client_base_path/remove' @@ -146,7 +146,7 @@ describe('BasePath', () => { ).toEqual('/remove'); }); - it('remove with clientBasePath provided but calls withoutClientBasePath', () => { + 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', From 5b81eeb4959ffb047c4aa0687e113a940b556e52 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 14 Mar 2024 17:13:07 +0800 Subject: [PATCH 17/17] Revert "feat: make the pr smaller" This reverts commit 9b66a28d8bc1f927fcdd7b5dc7151b3f3712103d. --- src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 +++ .../workspace_fatal_error.test.tsx.snap | 180 ++++++++++++++++++ .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.test.tsx | 71 +++++++ .../workspace_fatal_error.tsx | 68 +++++++ src/plugins/workspace/public/plugin.test.ts | 83 +++++++- src/plugins/workspace/public/plugin.ts | 65 ++++++- src/plugins/workspace/public/types.ts | 9 + 10 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx create mode 100644 src/plugins/workspace/public/types.ts 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 f34106ab4fed..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "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 e54a20552329..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,10 +28,15 @@ 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 () => { @@ -41,11 +49,82 @@ describe('Workspace plugin', () => { }, } 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); - expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); + 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 8a69d597c84b..8289351e0721 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,10 +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; @@ -33,9 +43,60 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); if (workspaceId) { - core.workspaces.currentWorkspaceId$.next(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 };