diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 1950d199074b..95e684610b42 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,18 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { i18n } from '@osd/i18n'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; -import { featureMatchesConfig } from './utils'; import { AppMountParameters, AppNavLinkStatus, CoreSetup, CoreStart, - LinksUpdater, Plugin, - WorkspaceObject, + AppUpdater, + AppStatus, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -28,7 +27,7 @@ import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; import { getWorkspaceColumn } from './components/workspace_column'; -import { NavLinkWrapper } from '../../../core/public/chrome/nav_links/nav_link'; +import { isAppAccessibleInWorkspace } from './utils'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -40,15 +39,15 @@ export class WorkspacePlugin implements Plugin<{}, {}> { private coreStart?: CoreStart; private currentWorkspaceIdSubscription?: Subscription; private currentWorkspaceSubscription?: Subscription; - - /** - * Filter the nav links based on the feature configuration of workspace - */ - private filterByWorkspace(allNavLinks: NavLinkWrapper[], workspace: WorkspaceObject | null) { - if (!workspace || !workspace.features) return allNavLinks; - - const featureFilter = featureMatchesConfig(workspace.features); - return allNavLinks.filter((linkWrapper) => featureFilter(linkWrapper.properties)); + private appUpdater$ = new BehaviorSubject(() => undefined); + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } } /** @@ -57,50 +56,29 @@ export class WorkspacePlugin implements Plugin<{}, {}> { */ private filterNavLinks(core: CoreStart) { const currentWorkspace$ = core.workspaces.currentWorkspace$; - let filterLinksByWorkspace: LinksUpdater; - this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { - const linkUpdaters$ = core.chrome.navLinks.getLinkUpdaters$(); - let linkUpdaters = linkUpdaters$.value; - - /** - * It should only have one link filter exist based on the current workspace at a given time - * So we need to filter out previous workspace link filter before adding new one after changing workspace - */ - linkUpdaters = linkUpdaters.filter((updater) => updater !== filterLinksByWorkspace); - - /** - * Whenever workspace changed, this function will filter out those links that should not - * be displayed. For example, some workspace may not have Observability features configured, in such case, - * the nav links of Observability features should not be displayed in left nav bar - */ - filterLinksByWorkspace = (navLinks) => { - const filteredNavLinks = this.filterByWorkspace([...navLinks.values()], currentWorkspace); - const newNavLinks = new Map(); - filteredNavLinks.forEach((chromeNavLink) => { - newNavLinks.set(chromeNavLink.id, chromeNavLink); + 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 }; }); - return newNavLinks; - }; - - linkUpdaters$.next([...linkUpdaters, filterLinksByWorkspace]); + } }); } - private _changeSavedObjectCurrentWorkspace() { - if (this.coreStart) { - return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { - if (currentWorkspaceId) { - this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); - } - }); - } - } - 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 */ @@ -245,5 +223,6 @@ export class WorkspacePlugin implements Plugin<{}, {}> { public stop() { this.currentWorkspaceIdSubscription?.unsubscribe(); 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 5ce89d9fffc6..046ada41b11c 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -3,8 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { featureMatchesConfig, getSelectedFeatureQuantities } from './utils'; +import { + featureMatchesConfig, + getSelectedFeatureQuantities, + isAppAccessibleInWorkspace, +} from './utils'; import { PublicAppInfo } from '../../../core/public'; +import { AppNavLinkStatus } from '../../../core/public'; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -137,3 +142,60 @@ describe('workspace utils: getSelectedFeatureQuantities', () => { expect(selected).toBe(0); }); }); + +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 2c0ad62d7775..327720be192e 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -4,10 +4,12 @@ */ import { + App, AppCategory, PublicAppInfo, AppNavLinkStatus, DEFAULT_APP_CATEGORIES, + WorkspaceObject, } from '../../../core/public'; /** @@ -30,6 +32,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 === '*') { @@ -83,3 +90,41 @@ export const getSelectedFeatureQuantities = ( selected: selectedApplications.length, }; }; +/** + * 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; +}