From 21a3f269e4622eac1a8771c66396fd732a319f02 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Mon, 11 Sep 2023 10:31:25 +0800 Subject: [PATCH] feat: add core workspace module The core workspace module(WorkspaceService) is a foundational component that enables the implementation of workspace features within OSD plugins. The purpose of the core workspace module is to provide a framework for workspace implementations. This module does not implement specific workspace functionality but provides the essential infrastructure for plugins to extend and customize workspace features, it maintains a shared workspace state(observables) across the entire application to ensure a consistent and up-to-date view of workspace-related information to all parts of the application. Signed-off-by: Yulong Ruan --- src/core/public/application/types.ts | 3 + src/core/public/core_system.ts | 8 ++ src/core/public/index.ts | 13 ++ src/core/public/mocks.ts | 4 + src/core/public/plugins/plugin_context.ts | 2 + .../public/plugins/plugins_service.test.ts | 3 + src/core/public/workspace/index.ts | 10 ++ .../workspace/workspaces_service.mock.ts | 36 +++++ .../public/workspace/workspaces_service.ts | 132 ++++++++++++++++++ src/core/types/index.ts | 1 + src/core/types/workspace.ts | 14 ++ 11 files changed, 226 insertions(+) create mode 100644 src/core/public/workspace/index.ts create mode 100644 src/core/public/workspace/workspaces_service.mock.ts create mode 100644 src/core/public/workspace/workspaces_service.ts create mode 100644 src/core/types/workspace.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7398aad65009..63d78436f5e1 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -47,6 +47,7 @@ import { IUiSettingsClient } from '../ui_settings'; import { SavedObjectsStart } from '../saved_objects'; import { AppCategory } from '../../types'; import { ScopedHistory } from './scoped_history'; +import { WorkspaceStart } from '../workspace'; /** * Accessibility status of an application. @@ -334,6 +335,8 @@ export interface AppMountContext { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; + /** {@link WorkspaceService} */ + workspaces: WorkspaceStart; }; } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 8fe5a36ebb55..cb7a1c84199a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -54,6 +54,7 @@ import { ContextService } from './context'; import { IntegrationsService } from './integrations'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; +import { WorkspaceService } from './workspace'; interface Params { rootDomElement: HTMLElement; @@ -110,6 +111,7 @@ export class CoreSystem { private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; + private readonly workspaces: WorkspaceService; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -138,6 +140,7 @@ export class CoreSystem { this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); + this.workspaces = new WorkspaceService(); this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; @@ -160,6 +163,7 @@ export class CoreSystem { const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); + const workspaces = this.workspaces.setup(); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ @@ -176,6 +180,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }; // Services that do not expose contracts at setup @@ -220,6 +225,7 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, overlays }); + const workspaces = this.workspaces.start({ application, http }); const chrome = await this.chrome.start({ application, docLinks, @@ -242,6 +248,7 @@ export class CoreSystem { overlays, savedObjects, uiSettings, + workspaces, })); const core: InternalCoreStart = { @@ -256,6 +263,7 @@ export class CoreSystem { overlays, uiSettings, fatalErrors, + workspaces, }; await this.plugins.start(core); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9a38771f513e..1c53ca8c8def 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -88,6 +88,7 @@ import { HandlerParameters, } from './context'; import { Branding } from '../types'; +import { WorkspaceStart, WorkspaceSetup } from './workspace'; export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ @@ -102,6 +103,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + WorkspaceAttribute, } from '../types'; export { @@ -239,6 +241,8 @@ export interface CoreSetup; + /** {@link WorkspaceSetup} */ + workspaces: WorkspaceSetup; } /** @@ -293,6 +297,8 @@ export interface CoreStart { getInjectedVar: (name: string, defaultValue?: any) => unknown; getBranding: () => Branding; }; + /** {@link WorkspaceStart} */ + workspaces: WorkspaceStart; } export { @@ -342,3 +348,10 @@ export { }; export { __osdBootstrap__ } from './osd_bootstrap'; + +export { + WorkspaceStart, + WorkspaceSetup, + WorkspaceService, + WorkspaceObservables, +} from './workspace'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index e863d627c801..722070d5a9ea 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -47,6 +47,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; +import { workspacesServiceMock } from './workspace/workspaces_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -60,6 +61,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; +export { workspacesServiceMock } from './workspace/workspaces_service.mock'; function createCoreSetupMock({ basePath = '', @@ -85,6 +87,7 @@ function createCoreSetupMock({ getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar, getBranding: injectedMetadataServiceMock.createSetupContract().getBranding, }, + workspaces: workspacesServiceMock.createSetupContractMock(), }; return mock; @@ -106,6 +109,7 @@ function createCoreStartMock({ basePath = '' } = {}) { getBranding: injectedMetadataServiceMock.createStartContract().getBranding, }, fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; return mock; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 42c40e91183f..87738fc7e57a 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -121,6 +121,7 @@ export function createPluginSetupContext< getBranding: deps.injectedMetadata.getBranding, }, getStartServices: () => plugin.startDependencies, + workspaces: deps.workspaces, }; } @@ -168,5 +169,6 @@ export function createPluginStartContext< getBranding: deps.injectedMetadata.getBranding, }, fatalErrors: deps.fatalErrors, + workspaces: deps.workspaces, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 5fa3bf888b5c..01578523be5f 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -58,6 +58,7 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; +import { workspacesServiceMock } from '../workspace/workspaces_service.mock'; export let mockPluginInitializers: Map; @@ -108,6 +109,7 @@ describe('PluginsService', () => { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + workspaces: workspacesServiceMock.createSetupContractMock(), }; mockSetupContext = { ...mockSetupDeps, @@ -127,6 +129,7 @@ describe('PluginsService', () => { uiSettings: uiSettingsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts new file mode 100644 index 000000000000..d83bb2c90909 --- /dev/null +++ b/src/core/public/workspace/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export { + WorkspaceStart, + WorkspaceService, + WorkspaceSetup, + WorkspaceObservables, +} from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts new file mode 100644 index 000000000000..3c35315aa850 --- /dev/null +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceAttribute } from '..'; + +const currentWorkspaceId$ = new BehaviorSubject(''); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); +const initialized$ = new BehaviorSubject(false); +const workspaceEnabled$ = new BehaviorSubject(false); + +const createWorkspacesSetupContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + workspaceEnabled$, + registerWorkspaceMenuRender: jest.fn(), +}); + +const createWorkspacesStartContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + workspaceEnabled$, + renderWorkspaceMenu: jest.fn(), +}); + +export const workspacesServiceMock = { + createSetupContractMock: createWorkspacesSetupContractMock, + createStartContract: createWorkspacesStartContractMock, +}; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts new file mode 100644 index 000000000000..e26c1b4aa73b --- /dev/null +++ b/src/core/public/workspace/workspaces_service.ts @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { isEqual } from 'lodash'; + +import { CoreService, WorkspaceAttribute } from '../../types'; +import { InternalApplicationStart } from '../application'; +import { HttpSetup } from '../http'; + +type WorkspaceMenuRenderFn = ({ + basePath, + getUrlForApp, + observables, +}: { + getUrlForApp: InternalApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + observables: WorkspaceObservables; +}) => JSX.Element | null; + +export interface WorkspaceObservables { + currentWorkspaceId$: BehaviorSubject; + currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; + workspaceEnabled$: BehaviorSubject; + initialized$: BehaviorSubject; +} + +enum WORKSPACE_ERROR { + WORKSPACE_STALED = 'WORKSPACE_STALED', +} + +/** + * @public + */ +export interface WorkspaceSetup extends WorkspaceObservables { + registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => void; +} + +export interface WorkspaceStart extends WorkspaceObservables { + renderWorkspaceMenu: () => JSX.Element | null; +} + +export class WorkspaceService implements CoreService { + private currentWorkspaceId$ = new BehaviorSubject(''); + private workspaceList$ = new BehaviorSubject([]); + private currentWorkspace$ = new BehaviorSubject(null); + private initialized$ = new BehaviorSubject(false); + private workspaceEnabled$ = new BehaviorSubject(false); + private _renderWorkspaceMenu: WorkspaceMenuRenderFn | null = null; + + constructor() { + combineLatest([this.initialized$, this.workspaceList$, this.currentWorkspaceId$]).subscribe( + ([workspaceInitialized, workspaceList, currentWorkspaceId]) => { + if (workspaceInitialized) { + const currentWorkspace = workspaceList.find((w) => w && w.id === currentWorkspaceId); + + /** + * Do a simple idempotent verification here + */ + if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { + this.currentWorkspace$.next(currentWorkspace ?? null); + } + + if (currentWorkspaceId && !currentWorkspace?.id) { + /** + * Current workspace is staled + */ + this.currentWorkspaceId$.error({ + reason: WORKSPACE_ERROR.WORKSPACE_STALED, + }); + this.currentWorkspace$.error({ + reason: WORKSPACE_ERROR.WORKSPACE_STALED, + }); + } + } + } + ); + } + + public setup(): WorkspaceSetup { + return { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + workspaceEnabled$: this.workspaceEnabled$, + registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => + (this._renderWorkspaceMenu = render), + }; + } + + public start({ + http, + application, + }: { + application: InternalApplicationStart; + http: HttpSetup; + }): WorkspaceStart { + const observables = { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + workspaceEnabled$: this.workspaceEnabled$, + }; + return { + ...observables, + renderWorkspaceMenu: () => { + if (this._renderWorkspaceMenu) { + return this._renderWorkspaceMenu({ + basePath: http.basePath, + getUrlForApp: application.getUrlForApp, + observables, + }); + } + return null; + }, + }; + } + + public async stop() { + this.currentWorkspace$.unsubscribe(); + this.currentWorkspaceId$.unsubscribe(); + this.workspaceList$.unsubscribe(); + this.workspaceEnabled$.unsubscribe(); + this.initialized$.unsubscribe(); + this._renderWorkspaceMenu = null; + } +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 9f620273e3b2..4afe9c537f75 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -39,3 +39,4 @@ export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; export * from './custom_branding'; +export * from './workspace'; diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts new file mode 100644 index 000000000000..23c3b2038ff2 --- /dev/null +++ b/src/core/types/workspace.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface WorkspaceAttribute { + id: string; + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; +}