Skip to content

Commit

Permalink
feat: add core workspace module (#145)
Browse files Browse the repository at this point in the history
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 <ruanyl@amazon.com>
  • Loading branch information
ruanyl committed Sep 22, 2023
1 parent 9c192a3 commit ab24720
Show file tree
Hide file tree
Showing 11 changed files with 741 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/core/public/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { IUiSettingsClient } from '../ui_settings';
import { SavedObjectsStart } from '../saved_objects';
import { AppCategory } from '../../types';
import { ScopedHistory } from './scoped_history';
import { WorkspacesStart } from '../workspace';

/**
* Accessibility status of an application.
Expand Down Expand Up @@ -334,6 +335,8 @@ export interface AppMountContext {
injectedMetadata: {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
};
/** {@link WorkspacesService} */
workspaces: WorkspacesStart;
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/core/public/core_system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { WorkspacesService } from './workspace';

interface Params {
rootDomElement: HTMLElement;
Expand Down Expand Up @@ -110,6 +111,7 @@ export class CoreSystem {

private readonly rootDomElement: HTMLElement;
private readonly coreContext: CoreContext;
private readonly workspaces: WorkspacesService;
private fatalErrorsSetup: FatalErrorsSetup | null = null;

constructor(params: Params) {
Expand Down Expand Up @@ -138,6 +140,7 @@ export class CoreSystem {
this.rendering = new RenderingService();
this.application = new ApplicationService();
this.integrations = new IntegrationsService();
this.workspaces = new WorkspacesService();

this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env };

Expand All @@ -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({
Expand All @@ -176,6 +180,7 @@ export class CoreSystem {
injectedMetadata,
notifications,
uiSettings,
workspaces,
};

// Services that do not expose contracts at setup
Expand Down Expand Up @@ -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,
Expand All @@ -242,6 +248,7 @@ export class CoreSystem {
overlays,
savedObjects,
uiSettings,
workspaces,
}));

const core: InternalCoreStart = {
Expand All @@ -256,6 +263,7 @@ export class CoreSystem {
overlays,
uiSettings,
fatalErrors,
workspaces,
};

await this.plugins.start(core);
Expand Down
13 changes: 13 additions & 0 deletions src/core/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
HandlerParameters,
} from './context';
import { Branding } from '../types';
import { WorkspacesStart, WorkspacesSetup } from './workspace';

export type { Logos } from '../common';
export { PackageInfo, EnvironmentMode } from '../server/types';
Expand All @@ -102,6 +103,7 @@ export {
StringValidation,
StringValidationRegex,
StringValidationRegexString,
WorkspaceAttribute,
} from '../types';

export {
Expand Down Expand Up @@ -239,6 +241,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
};
/** {@link StartServicesAccessor} */
getStartServices: StartServicesAccessor<TPluginsStart, TStart>;
/** {@link WorkspacesSetup} */
workspaces: WorkspacesSetup;
}

/**
Expand Down Expand Up @@ -293,6 +297,8 @@ export interface CoreStart {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
getBranding: () => Branding;
};
/** {@link WorkspacesStart} */
workspaces: WorkspacesStart;
}

export {
Expand Down Expand Up @@ -341,3 +347,10 @@ export {
};

export { __osdBootstrap__ } from './osd_bootstrap';

export {
WorkspacesStart,
WorkspacesSetup,
WorkspacesService,
WorkspaceObservables,
} from './workspace';
4 changes: 4 additions & 0 deletions src/core/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = '',
Expand All @@ -85,6 +87,7 @@ function createCoreSetupMock({
getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar,
getBranding: injectedMetadataServiceMock.createSetupContract().getBranding,
},
workspaces: workspacesServiceMock.createSetupContractMock(),
};

return mock;
Expand All @@ -106,6 +109,7 @@ function createCoreStartMock({ basePath = '' } = {}) {
getBranding: injectedMetadataServiceMock.createStartContract().getBranding,
},
fatalErrors: fatalErrorsServiceMock.createStartContract(),
workspaces: workspacesServiceMock.createStartContract(),
};

return mock;
Expand Down
2 changes: 2 additions & 0 deletions src/core/public/plugins/plugin_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export function createPluginSetupContext<
getBranding: deps.injectedMetadata.getBranding,
},
getStartServices: () => plugin.startDependencies,
workspaces: deps.workspaces,
};
}

Expand Down Expand Up @@ -168,5 +169,6 @@ export function createPluginStartContext<
getBranding: deps.injectedMetadata.getBranding,
},
fatalErrors: deps.fatalErrors,
workspaces: deps.workspaces,
};
}
3 changes: 3 additions & 0 deletions src/core/public/plugins/plugins_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginName, MockedPluginInitializer>;

Expand Down Expand Up @@ -108,6 +109,7 @@ describe('PluginsService', () => {
injectedMetadata: injectedMetadataServiceMock.createStartContract(),
notifications: notificationServiceMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
workspaces: workspacesServiceMock.createSetupContractMock(),
};
mockSetupContext = {
...mockSetupDeps,
Expand All @@ -127,6 +129,7 @@ describe('PluginsService', () => {
uiSettings: uiSettingsServiceMock.createStartContract(),
savedObjects: savedObjectsServiceMock.createStartContract(),
fatalErrors: fatalErrorsServiceMock.createStartContract(),
workspaces: workspacesServiceMock.createStartContract(),
};
mockStartContext = {
...mockStartDeps,
Expand Down
10 changes: 10 additions & 0 deletions src/core/public/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
export {
WorkspacesStart,
WorkspacesService,
WorkspacesSetup,
WorkspaceObservables,
} from './workspaces_service';
36 changes: 36 additions & 0 deletions src/core/public/workspace/workspaces_service.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { BehaviorSubject } from 'rxjs';
import { WorkspaceAttribute } from '..';

const currentWorkspaceId$ = new BehaviorSubject<string>('');
const workspaceList$ = new BehaviorSubject<WorkspaceAttribute[]>([]);
const currentWorkspace$ = new BehaviorSubject<WorkspaceAttribute | null>(null);
const initialized$ = new BehaviorSubject<boolean>(false);
const workspaceEnabled$ = new BehaviorSubject<boolean>(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,
};
134 changes: 134 additions & 0 deletions src/core/public/workspace/workspaces_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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;

type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean };

export interface WorkspaceObservables {
currentWorkspaceId$: BehaviorSubject<string>;
currentWorkspace$: BehaviorSubject<WorkspaceObject | null>;
workspaceList$: BehaviorSubject<WorkspaceObject[]>;
workspaceEnabled$: BehaviorSubject<boolean>;
initialized$: BehaviorSubject<boolean>;
}

enum WORKSPACE_ERROR {
WORKSPACE_STALED = 'WORKSPACE_STALED',
}

/**
* @public
*/
export interface WorkspacesSetup extends WorkspaceObservables {
registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => void;
}

export interface WorkspacesStart extends WorkspaceObservables {
renderWorkspaceMenu: () => JSX.Element | null;
}

export class WorkspacesService implements CoreService<WorkspacesSetup, WorkspacesStart> {
private currentWorkspaceId$ = new BehaviorSubject<string>('');
private workspaceList$ = new BehaviorSubject<WorkspaceObject[]>([]);
private currentWorkspace$ = new BehaviorSubject<WorkspaceObject | null>(null);
private initialized$ = new BehaviorSubject<boolean>(false);
private workspaceEnabled$ = new BehaviorSubject<boolean>(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(): WorkspacesSetup {
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;
}): WorkspacesStart {
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;
}
}
Loading

0 comments on commit ab24720

Please sign in to comment.