Skip to content

Commit

Permalink
[Workspace] Filter left nav menu items according to the current works…
Browse files Browse the repository at this point in the history
…pace (opensearch-project#6234) (#323)

* Filter left nav menu items according to the current workspace

An "Application Not Found" page will be displayed if accessing app which
is not configured by the workspace

---------

Signed-off-by: Yulong Ruan <ruanyl@amazon.com>
Signed-off-by: SuZhou-Joe <suzhou@amazon.com>
Co-authored-by: Yulong Ruan <ruanyl@amazon.com>
  • Loading branch information
SuZhou-Joe and ruanyl authored Apr 11, 2024
1 parent e2a8ae2 commit ccbf3b4
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 50 deletions.
77 changes: 28 additions & 49 deletions src/plugins/workspace/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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<AppUpdater>(() => undefined);
private _changeSavedObjectCurrentWorkspace() {
if (this.coreStart) {
return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => {
if (currentWorkspaceId) {
this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId);
}
});
}
}

/**
Expand All @@ -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<string, NavLinkWrapper>();
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
*/
Expand Down Expand Up @@ -245,5 +223,6 @@ export class WorkspacePlugin implements Plugin<{}, {}> {
public stop() {
this.currentWorkspaceIdSubscription?.unsubscribe();
this.currentWorkspaceSubscription?.unsubscribe();
this.currentWorkspaceIdSubscription?.unsubscribe();
}
}
64 changes: 63 additions & 1 deletion src/plugins/workspace/public/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
45 changes: 45 additions & 0 deletions src/plugins/workspace/public/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
*/

import {
App,
AppCategory,
PublicAppInfo,
AppNavLinkStatus,
DEFAULT_APP_CATEGORIES,
WorkspaceObject,
} from '../../../core/public';

/**
Expand All @@ -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 === '*') {
Expand Down Expand Up @@ -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;
}

0 comments on commit ccbf3b4

Please sign in to comment.