From ccbf3b45f284200ffd8c6c34110dd91264c7570c Mon Sep 17 00:00:00 2001
From: SuZhou-Joe <suzhou@amazon.com>
Date: Thu, 11 Apr 2024 14:53:59 +0800
Subject: [PATCH] [Workspace] Filter left nav menu items according to the
 current workspace (#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>
---
 src/plugins/workspace/public/plugin.ts     | 77 ++++++++--------------
 src/plugins/workspace/public/utils.test.ts | 64 +++++++++++++++++-
 src/plugins/workspace/public/utils.ts      | 45 +++++++++++++
 3 files changed, 136 insertions(+), 50 deletions(-)

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<AppUpdater>(() => 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<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
      */
@@ -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;
+}