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 (#6234)

* 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: SuZhou-Joe <suzhou@amazon.com>
  • Loading branch information
ruanyl and SuZhou-Joe authored Apr 10, 2024
1 parent b70c327 commit cb9b26c
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Multiple Datasource] Add installedPlugins list to data source saved object ([#6348](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6348))
- [Multiple Datasource] Add default icon in multi-selectable picker ([#6357](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6357))
- [Workspace] Add APIs to support plugin state in request ([#6303](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6303))
- [Workspace] Filter left nav menu items according to the current workspace ([#6234](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6234))

### 🐛 Bug Fixes

Expand Down
39 changes: 37 additions & 2 deletions src/plugins/workspace/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,6 +12,8 @@ import {
CoreSetup,
AppMountParameters,
AppNavLinkStatus,
AppUpdater,
AppStatus,
} from '../../../core/public';
import {
WORKSPACE_FATAL_ERROR_APP_ID,
Expand All @@ -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;

Expand All @@ -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<AppUpdater>(() => undefined);
private _changeSavedObjectCurrentWorkspace() {
if (this.coreStart) {
return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => {
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
}
60 changes: 59 additions & 1 deletion src/plugins/workspace/public/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
46 changes: 45 additions & 1 deletion src/plugins/workspace/public/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 === '*') {
Expand Down Expand Up @@ -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;
}

0 comments on commit cb9b26c

Please sign in to comment.