diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5357aa08cbf7..b27c4b3bdd4a 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { @@ -12,6 +12,8 @@ import { CoreSetup, AppMountParameters, AppNavLinkStatus, + AppUpdater, + AppStatus, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -26,6 +28,7 @@ import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; import { getWorkspaceColumn } from './components/workspace_column'; +import { isAppAccessibleInWorkspace } from './utils'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -36,6 +39,8 @@ interface WorkspacePluginSetupDeps { export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; + private currentWorkspaceIdSubscription?: Subscription; + private appUpdater$ = new BehaviorSubject(() => undefined); private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { @@ -46,9 +51,34 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> } } + /** + * Filter nav links by the current workspace, once the current workspace change, the nav links(left nav bar) + * should also be updated according to the configured features of the current workspace + */ + private filterNavLinks(core: CoreStart) { + const currentWorkspace$ = core.workspaces.currentWorkspace$; + this.currentWorkspaceSubscription?.unsubscribe(); + + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + if (currentWorkspace) { + this.appUpdater$.next((app) => { + if (isAppAccessibleInWorkspace(app, currentWorkspace)) { + return; + } + /** + * Change the app to `inaccessible` if it is not configured in the workspace + * If trying to access such app, an "Application Not Found" page will be displayed + */ + return { status: AppStatus.inaccessible }; + }); + } + }); + } + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); + core.application.registerAppUpdater(this.appUpdater$); /** * Retrieve workspace id from url @@ -171,11 +201,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> public start(core: CoreStart) { this.coreStart = core; - this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); + this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); + + // When starts, filter the nav links based on the current workspace + this.filterNavLinks(core); + return {}; } public stop() { this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceIdSubscription?.unsubscribe(); } } diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 510a775cd745..f81e248c4469 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { featureMatchesConfig } from './utils'; +import { AppNavLinkStatus } from '../../../core/public'; +import { featureMatchesConfig, isAppAccessibleInWorkspace } from './utils'; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -91,3 +92,60 @@ describe('workspace utils: featureMatchesConfig', () => { ); }); }); + +describe('workspace utils: isAppAccessibleInWorkspace', () => { + it('any app is accessible when workspace has no features configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'any_app', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name' } + ) + ).toBe(true); + }); + + it('An app is accessible when the workspace has the app configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name', features: ['dev_tools'] } + ) + ).toBe(true); + }); + + it('An app is not accessible when the workspace does not have the app configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(false); + }); + + it('An app is accessible if the nav link is hidden', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'dev_tools', + title: 'Any app', + mount: jest.fn(), + navLinkStatus: AppNavLinkStatus.hidden, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(true); + }); + + it('An app is accessible if it is chromeless', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'dev_tools', + title: 'Any app', + mount: jest.fn(), + chromeless: true, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(true); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 444b3aadadf3..e70a26028525 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppCategory } from '../../../core/public'; +import { App, AppCategory, AppNavLinkStatus, WorkspaceObject } from '../../../core/public'; /** * Checks if a given feature matches the provided feature configuration. @@ -25,6 +25,11 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ }) => { let matched = false; + /** + * Iterate through each feature configuration to determine if the given feature matches any of them. + * Note: The loop will not break prematurely because the order of featureConfigs array matters. + * Later configurations may override previous ones, so each configuration must be evaluated in sequence. + */ for (const featureConfig of featureConfigs) { // '*' matches any feature if (featureConfig === '*') { @@ -55,3 +60,42 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ return matched; }; + +/** + * Check if an app is accessible in a workspace based on the workspace configured features + */ +export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) { + /** + * When workspace has no features configured, all apps are considered to be accessible + */ + if (!workspace.features) { + return true; + } + + /** + * The app is configured into a workspace, it is accessible after entering the workspace + */ + const featureMatcher = featureMatchesConfig(workspace.features); + if (featureMatcher({ id: app.id, category: app.category })) { + return true; + } + + /* + * An app with hidden nav link is not configurable by workspace, which means user won't be + * able to select/unselect it when configuring workspace features. Such apps are by default + * accessible when in a workspace. + */ + if (app.navLinkStatus === AppNavLinkStatus.hidden) { + return true; + } + + /** + * A chromeless app is not configurable by workspace, which means user won't be + * able to select/unselect it when configuring workspace features. Such apps are by default + * accessible when in a workspace. + */ + if (app.chromeless) { + return true; + } + return false; +}