From 1c1a86296738c2c2702ba22469d2aa36ff516a3c Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Mon, 22 Jun 2026 18:35:45 +0000 Subject: [PATCH 1/3] Fix single perspective icon and text in NavHeader --- frontend/@types/console/window.d.ts | 1 + .../providers/perspective-state-provider.ts | 11 +-- .../PerspectiveConfiguration.tsx | 31 ++----- .../__tests__/PerspectiveDetector.spec.tsx | 20 ++-- .../src/components/nav/NavHeader.tsx | 43 +++++---- .../nav/__tests__/NavHeader.spec.tsx | 31 +++++-- .../hooks/__tests__/usePerspectives.spec.ts | 50 ++++++---- .../__tests__/usePinnedResources.spec.ts | 91 +++++++++++++++---- .../src/hooks/usePerspectives.ts | 50 +++------- .../src/hooks/usePinnedResources.ts | 8 +- .../src/utils/override-perspectives.ts | 56 ++++++++++++ .../catalog/PinnedResourcesConfiguration.tsx | 10 +- pkg/server/server.go | 12 ++- 13 files changed, 261 insertions(+), 153 deletions(-) create mode 100644 frontend/packages/console-shared/src/utils/override-perspectives.ts diff --git a/frontend/@types/console/window.d.ts b/frontend/@types/console/window.d.ts index 5baf55c51a6..a0fc0b463ae 100644 --- a/frontend/@types/console/window.d.ts +++ b/frontend/@types/console/window.d.ts @@ -31,6 +31,7 @@ declare interface Window { GOOS: string; graphqlBaseURL: string; developerCatalogCategories: string; + /** JSON encoded configuration for the console's perspectives override */ perspectives: string; developerCatalogTypes: string; userSettingsLocation: string; diff --git a/frontend/packages/console-app/src/actions/providers/perspective-state-provider.ts b/frontend/packages/console-app/src/actions/providers/perspective-state-provider.ts index a04559a9588..47d62c7a022 100644 --- a/frontend/packages/console-app/src/actions/providers/perspective-state-provider.ts +++ b/frontend/packages/console-app/src/actions/providers/perspective-state-provider.ts @@ -1,17 +1,16 @@ import type { SetFeatureFlag } from '@console/dynamic-plugin-sdk'; -import type { Perspective } from '@console/shared/src/hooks/usePerspectives'; +import { hasReviewAccess } from '@console/shared/src/hooks/usePerspectives'; import { - hasReviewAccess, PerspectiveVisibilityState, -} from '@console/shared/src/hooks/usePerspectives'; + overridePerspectives, +} from '@console/shared/src/utils/override-perspectives'; import { FLAG_DEVELOPER_PERSPECTIVE } from '../../consts'; export const useDeveloperPerspectiveStateProvider = (setFeatureFlag: SetFeatureFlag) => { - if (!window.SERVER_FLAGS.perspectives) { + if (!overridePerspectives) { setFeatureFlag(FLAG_DEVELOPER_PERSPECTIVE, true); } else { - const perspectives: Perspective[] = JSON.parse(window.SERVER_FLAGS.perspectives); - const devPerspective = perspectives?.find((p) => p.id === 'dev'); + const devPerspective = overridePerspectives.find((p) => p.id === 'dev'); if (!devPerspective) { setFeatureFlag(FLAG_DEVELOPER_PERSPECTIVE, true); } else if (devPerspective.visibility.state === PerspectiveVisibilityState.Disabled) { diff --git a/frontend/packages/console-app/src/components/detect-context/PerspectiveConfiguration.tsx b/frontend/packages/console-app/src/components/detect-context/PerspectiveConfiguration.tsx index 976ca5cb6ac..ae64986f02d 100644 --- a/frontend/packages/console-app/src/components/detect-context/PerspectiveConfiguration.tsx +++ b/frontend/packages/console-app/src/components/detect-context/PerspectiveConfiguration.tsx @@ -14,10 +14,7 @@ import { } from '@patternfly/react-core'; import { safeDump } from 'js-yaml'; import { useTranslation } from 'react-i18next'; -import type { - Perspective as PerspectiveExtension, - AccessReviewResourceAttributes, -} from '@console/dynamic-plugin-sdk/src'; +import type { Perspective as PerspectiveExtension } from '@console/dynamic-plugin-sdk/src'; import { isPerspective } from '@console/dynamic-plugin-sdk/src'; import type { K8sResourceKind } from '@console/internal/module/k8s'; import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions'; @@ -29,27 +26,11 @@ import { SaveStatus } from '@console/shared/src/components/cluster-configuration import { useConsoleOperatorConfig } from '@console/shared/src/components/cluster-configuration/useConsoleOperatorConfig'; import { useDebounceCallback } from '@console/shared/src/hooks/useDebounceCallback'; import { useTelemetry } from '@console/shared/src/hooks/useTelemetry'; - -enum PerspectiveVisibilityState { - Enabled = 'Enabled', - Disabled = 'Disabled', - AccessReview = 'AccessReview', -} - -type PerspectiveAccessReview = { - required?: AccessReviewResourceAttributes[]; - missing?: AccessReviewResourceAttributes[]; -}; - -type PerspectiveVisibility = { - state: PerspectiveVisibilityState; - accessReview?: PerspectiveAccessReview; -}; - -type Perspective = { - id: string; - visibility: PerspectiveVisibility; -}; +import type { + PerspectiveVisibility, + Perspective, +} from '@console/shared/src/utils/override-perspectives'; +import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; type PerspectivesConsoleConfig = K8sResourceKind & { spec: { diff --git a/frontend/packages/console-app/src/components/detect-context/__tests__/PerspectiveDetector.spec.tsx b/frontend/packages/console-app/src/components/detect-context/__tests__/PerspectiveDetector.spec.tsx index 3a786c08d69..60abfe93786 100644 --- a/frontend/packages/console-app/src/components/detect-context/__tests__/PerspectiveDetector.spec.tsx +++ b/frontend/packages/console-app/src/components/detect-context/__tests__/PerspectiveDetector.spec.tsx @@ -2,13 +2,20 @@ import { render, waitFor } from '@testing-library/react'; import { useLocation } from 'react-router'; import type { Perspective } from '@console/dynamic-plugin-sdk'; import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; -import { - usePerspectives, - PerspectiveVisibilityState, -} from '@console/shared/src/hooks/usePerspectives'; -import type { Perspective as PerspectiveType } from '@console/shared/src/hooks/usePerspectives'; +import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import type { Perspective as PerspectiveType } from '@console/shared/src/utils/override-perspectives'; +import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; import PerspectiveDetector from '../PerspectiveDetector'; +let mockOverridePerspectives: PerspectiveType[] | undefined; + +jest.mock('@console/shared/src/utils/override-perspectives', () => ({ + ...jest.requireActual('@console/shared/src/utils/override-perspectives'), + get overridePerspectives() { + return mockOverridePerspectives; + }, +})); + jest.mock('@console/shared/src/hooks/usePerspectives', () => ({ ...jest.requireActual('@console/shared/src/hooks/usePerspectives'), usePerspectives: jest.fn(), @@ -94,7 +101,7 @@ describe('PerspectiveDetector', () => { }); it('should set admin as default perspective when all perspectives are disabled', async () => { - const perspectives: PerspectiveType[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -122,7 +129,6 @@ describe('PerspectiveDetector', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); let promiseResolver: (value: () => [boolean, boolean]) => void; const testPromise = new Promise<() => [boolean, boolean]>( diff --git a/frontend/packages/console-app/src/components/nav/NavHeader.tsx b/frontend/packages/console-app/src/components/nav/NavHeader.tsx index 266fe771a2b..d872b0912b4 100644 --- a/frontend/packages/console-app/src/components/nav/NavHeader.tsx +++ b/frontend/packages/console-app/src/components/nav/NavHeader.tsx @@ -74,11 +74,23 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { /> )); - const { icon, name } = useMemo( + const { icon, name } = useMemo<{ + icon: Perspective['properties']['icon']; + name: Perspective['properties']['name']; + }>( () => perspectiveExtensions.find((p) => p?.properties?.id === activePerspective)?.properties ?? - perspectiveExtensions[0]?.properties ?? { icon: null, name: null }, - [activePerspective, perspectiveExtensions], + perspectiveExtensions[0]?.properties ?? { icon: null, name: t('Core platform') }, + [activePerspective, perspectiveExtensions, t], + ); + + const ActivePerspectiveIcon = icon ? ( + icon().then((m) => m.default)} + LoadingComponent={IconLoadingComponent} + /> + ) : ( + ); return perspectiveDropdownItems.length > 1 ? ( @@ -99,20 +111,11 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { isExpanded={isPerspectiveDropdownOpen} ref={toggleRef} onClick={() => togglePerspectiveOpen()} - icon={ - icon && ( - icon().then((m) => m.default)} - LoadingComponent={IconLoadingComponent} - /> - ) - } + icon={ActivePerspectiveIcon} > - {name && ( - - {name} - - )} + + {name} + )} popperProps={{ @@ -123,13 +126,9 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { ) : ( -
+
- <RhUiGearGroupFillIcon /> {t('Core platform')} + {ActivePerspectiveIcon} {name}
); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx index 6db823b2d4c..de9833abfe3 100644 --- a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx @@ -1,9 +1,20 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; +import type { Perspective } from '@console/shared/src/utils/override-perspectives'; +import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; import NavHeader from '../NavHeader'; import { renderWithPerspective } from './navTestUtils'; +let mockOverridePerspectives: Perspective[]; + +jest.mock('@console/shared/src/utils/override-perspectives', () => ({ + ...jest.requireActual('@console/shared/src/utils/override-perspectives'), + get overridePerspectives() { + return mockOverridePerspectives; + }, +})); + jest.mock('@console/internal/components/utils/async', () => ({ AsyncComponent: () => null, })); @@ -89,14 +100,14 @@ describe('NavHeader', () => { describe('when only one perspective is available', () => { beforeEach(() => { - window.SERVER_FLAGS.perspectives = JSON.stringify([ - { id: 'admin', visibility: { state: 'Enabled' } }, - { id: 'dev', visibility: { state: 'Disabled' } }, - ]); + mockOverridePerspectives = [ + { id: 'admin', visibility: { state: PerspectiveVisibilityState.Enabled } }, + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Disabled } }, + ]; }); afterEach(() => { - delete window.SERVER_FLAGS.perspectives; + mockOverridePerspectives = undefined; }); it('should render static label instead of dropdown', () => { @@ -109,14 +120,14 @@ describe('NavHeader', () => { describe('when all perspectives are disabled', () => { beforeEach(() => { - window.SERVER_FLAGS.perspectives = JSON.stringify([ - { id: 'admin', visibility: { state: 'Disabled' } }, - { id: 'dev', visibility: { state: 'Disabled' } }, - ]); + mockOverridePerspectives = [ + { id: 'admin', visibility: { state: PerspectiveVisibilityState.Disabled } }, + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Disabled } }, + ]; }); afterEach(() => { - delete window.SERVER_FLAGS.perspectives; + mockOverridePerspectives = undefined; }); it('should fall back to static label for admin perspective', () => { diff --git a/frontend/packages/console-shared/src/hooks/__tests__/usePerspectives.spec.ts b/frontend/packages/console-shared/src/hooks/__tests__/usePerspectives.spec.ts index 89976fa46ed..ca486a6f3da 100644 --- a/frontend/packages/console-shared/src/hooks/__tests__/usePerspectives.spec.ts +++ b/frontend/packages/console-shared/src/hooks/__tests__/usePerspectives.spec.ts @@ -1,19 +1,31 @@ import { renderHook, waitFor } from '@testing-library/react'; import { checkAccess } from '@console/dynamic-plugin-sdk/src/app/components/utils/rbac'; import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions'; -import { usePerspectives, PerspectiveVisibilityState } from '../usePerspectives'; -import type { Perspective } from '../usePerspectives'; +import type { Perspective } from '@console/shared/src/utils/override-perspectives'; +import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; +import { usePerspectives } from '../usePerspectives'; const useExtensionsMock = useExtensions as jest.Mock; +let mockOverridePerspectives: Perspective[]; + +jest.mock('@console/shared/src/utils/override-perspectives', () => ({ + ...jest.requireActual('@console/shared/src/utils/override-perspectives'), + get overridePerspectives() { + return mockOverridePerspectives; + }, +})); + jest.mock('@console/plugin-sdk/src/api/useExtensions', () => ({ useExtensions: jest.fn() })); jest.mock('@console/dynamic-plugin-sdk/src/app/components/utils/rbac', () => ({ checkAccess: jest.fn(), })); + describe('usePerspectives', () => { beforeEach(() => { - window.SERVER_FLAGS.perspectives = undefined; + mockOverridePerspectives = undefined; + useExtensionsMock.mockClear(); useExtensionsMock.mockReturnValue([ { @@ -43,7 +55,7 @@ describe('usePerspectives', () => { }); it('should return all the available perspectives if perspectives are not set in the server flags', async () => { - window.SERVER_FLAGS.perspectives = undefined; + mockOverridePerspectives = undefined; const { result } = renderHook(() => usePerspectives()); @@ -75,7 +87,7 @@ describe('usePerspectives', () => { }); it('should return all the available perspectives if perspectives are not configured in the server flags', async () => { - window.SERVER_FLAGS.perspectives = ''; + mockOverridePerspectives = undefined; const { result } = renderHook(() => usePerspectives()); @@ -107,7 +119,7 @@ describe('usePerspectives', () => { }); it('should return only the enabled perspectives and the perspectives that satisfy the missing accessreview checks that are set in the server flags', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -135,7 +147,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: true } })); const { result } = renderHook(() => usePerspectives()); @@ -154,7 +166,7 @@ describe('usePerspectives', () => { }); it('should return the admin perspective as default if all the perspectives are disabled', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -182,7 +194,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: true } })); const { result } = renderHook(() => usePerspectives()); @@ -201,7 +213,7 @@ describe('usePerspectives', () => { }); it('should return only the enabled perspectives and the perspectives that satisfy the required accessreview checks that are set in the server flags', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -229,7 +241,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: true } })); const { result } = renderHook(() => usePerspectives()); @@ -256,7 +268,7 @@ describe('usePerspectives', () => { }); it('should handle perspectives with accessReview checks', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -285,7 +297,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: true } })); const { result } = renderHook(() => usePerspectives()); @@ -304,7 +316,7 @@ describe('usePerspectives', () => { }); it('should return only the enabled perspectives and the perspectives that satisfy the required accessreview checks that are set in the server flags for user with limited access', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -332,7 +344,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: false } })); const { result } = renderHook(() => usePerspectives()); @@ -351,7 +363,7 @@ describe('usePerspectives', () => { }); it('should not return perspective when required accessreview check throws an error', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -379,7 +391,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.reject(new Error('Unexpected error'))); const { result } = renderHook(() => usePerspectives()); @@ -398,7 +410,7 @@ describe('usePerspectives', () => { }); it('should also return perspectives that are not configured', async () => { - const perspectives: Perspective[] = [ + mockOverridePerspectives = [ { id: 'dev', visibility: { @@ -414,7 +426,7 @@ describe('usePerspectives', () => { }, }, ]; - window.SERVER_FLAGS.perspectives = JSON.stringify(perspectives); + (checkAccess as jest.Mock).mockReturnValue(Promise.resolve({ status: { allowed: true } })); const { result } = renderHook(() => usePerspectives()); diff --git a/frontend/packages/console-shared/src/hooks/__tests__/usePinnedResources.spec.ts b/frontend/packages/console-shared/src/hooks/__tests__/usePinnedResources.spec.ts index 1072f5b64b7..af88bcf8b46 100644 --- a/frontend/packages/console-shared/src/hooks/__tests__/usePinnedResources.spec.ts +++ b/frontend/packages/console-shared/src/hooks/__tests__/usePinnedResources.spec.ts @@ -4,6 +4,8 @@ import { DeploymentModel } from '@console/dynamic-plugin-sdk/src/utils/k8s/__moc import { ConfigMapModel } from '@console/internal/models'; import { useModelFinder } from '@console/internal/module/k8s/k8s-models'; import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import type { Perspective } from '../../utils/override-perspectives'; +import { PerspectiveVisibilityState } from '../../utils/override-perspectives'; import { usePinnedResources } from '../usePinnedResources'; import { useUserPreference } from '../useUserPreference'; @@ -13,6 +15,15 @@ const useUserPreferenceMock = useUserPreference as jest.Mock; const useModelFinderMock = useModelFinder as jest.Mock; const setPinnedResourcesMock = jest.fn(); +let mockOverridePerspectives: Perspective[]; + +jest.mock('@console/shared/src/utils/override-perspectives', () => ({ + ...jest.requireActual('@console/shared/src/utils/override-perspectives'), + get overridePerspectives() { + return mockOverridePerspectives; + }, +})); + jest.mock('@console/shared/src/hooks/usePerspectives', () => ({ usePerspectives: jest.fn() })); jest.mock('@console/dynamic-plugin-sdk/src/perspective/useActivePerspective', () => ({ default: jest.fn(), @@ -63,7 +74,7 @@ describe('usePinnedResources', () => { }); it('should return default pins from extension if perspectives are not configured', async () => { - window.SERVER_FLAGS.perspectives = ''; + mockOverridePerspectives = undefined; useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockImplementation((configKey, defaultPins) => [ defaultPins, @@ -85,8 +96,14 @@ describe('usePinnedResources', () => { }); it('should return an empty array if user settings are not loaded yet', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources" : [{"version" : "v1", "resource" : "deployments"}]}]'; + mockOverridePerspectives = [ + { + id: 'dev', + visibility: { state: PerspectiveVisibilityState.Enabled }, + pinnedResources: [{ version: 'v1', resource: 'deployments' }], + }, + ]; + // Mock user preference data useUserPreferenceMock.mockReturnValue([null, setPinnedResourcesMock, false]); @@ -100,7 +117,10 @@ describe('usePinnedResources', () => { }); it('should not return any pins if no pins are configured and no extension could be found', async () => { - window.SERVER_FLAGS.perspectives = '[{ "id" : "dev", "visibility": {"state" : "Enabled" }}]'; + mockOverridePerspectives = [ + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Enabled } }, + ]; + // Mock empty old data useUserPreferenceMock.mockReturnValue([{}, setPinnedResourcesMock, true]); usePerspectivesMock.mockClear(); @@ -115,8 +135,11 @@ describe('usePinnedResources', () => { expect(setPinnedResourcesMock).toHaveBeenCalledTimes(0); }); - it('should not return any pins if no pins are configured and extension donot have default pins', async () => { - window.SERVER_FLAGS.perspectives = '[{ "id" : "dev", "visibility": {"state" : "Enabled" }}]'; + it('should not return any pins if no pins are configured and extension do not have default pins', async () => { + mockOverridePerspectives = [ + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Enabled } }, + ]; + // Mock empty old data useUserPreferenceMock.mockReturnValue([{}, setPinnedResourcesMock, true]); usePerspectivesMock.mockClear(); @@ -140,8 +163,10 @@ describe('usePinnedResources', () => { }); it('should not return any pins if pins configured is an empty array and the extension has default pins', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources": []}]'; + mockOverridePerspectives = [ + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Enabled }, pinnedResources: [] }, + ]; + // Mock empty old data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockImplementation((configKey, defaultPins) => [ @@ -160,8 +185,14 @@ describe('usePinnedResources', () => { }); it('should return default pins if pins configured is null and the extension has default pins', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources": null}]'; + mockOverridePerspectives = [ + { + id: 'dev', + visibility: { state: PerspectiveVisibilityState.Enabled }, + pinnedResources: null, + }, + ]; + // Mock empty old data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockImplementation((configKey, storageKey, defaultPins) => [ @@ -184,7 +215,10 @@ describe('usePinnedResources', () => { }); it('should return default pins from extension if there are no pinned resources configured by and the extension has default pins', async () => { - window.SERVER_FLAGS.perspectives = '[{ "id" : "dev", "visibility": {"state" : "Enabled" }}]'; + mockOverridePerspectives = [ + { id: 'dev', visibility: { state: PerspectiveVisibilityState.Enabled } }, + ]; + // Mock empty old data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockImplementation((configKey, storageKey, defaultPins) => [ @@ -207,8 +241,14 @@ describe('usePinnedResources', () => { }); it('should return customized pins if the pins are not customized by the user and the extension has default pins', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources" : [{"version" : "v1", "resource" : "deployments", "group": "apps"}]}]'; + mockOverridePerspectives = [ + { + id: 'dev', + visibility: { state: PerspectiveVisibilityState.Enabled }, + pinnedResources: [{ version: 'v1', resource: 'deployments', group: 'apps' }], + }, + ]; + // Mock empty old data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockImplementation((configKey, storageKey, defaultPins) => [ @@ -227,8 +267,14 @@ describe('usePinnedResources', () => { }); it('should return an array of pins saved in user settings for the current perspective', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources" : [{"version" : "v1", "resource" : "deployments"}]}]'; + mockOverridePerspectives = [ + { + id: 'dev', + visibility: { state: PerspectiveVisibilityState.Enabled }, + pinnedResources: [{ version: 'v1', resource: 'deployments' }], + }, + ]; + // Mock user settings data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockReturnValue([ @@ -250,9 +296,18 @@ describe('usePinnedResources', () => { expect(setPinnedResourcesMock).toHaveBeenCalledTimes(0); }); - it('should return configured pins and filter out pins with resources that donot exist', async () => { - window.SERVER_FLAGS.perspectives = - '[{ "id" : "dev", "visibility": {"state" : "Enabled" }, "pinnedResources" : [{"version" : "v1", "resource" : "deploymentss", "group" : "apps" },{"version" : "v1", "resource" : "configmaps", "group" : "" } ]}]'; + it('should return configured pins and filter out pins with resources that do not exist', async () => { + mockOverridePerspectives = [ + { + id: 'dev', + visibility: { state: PerspectiveVisibilityState.Enabled }, + pinnedResources: [ + { version: 'v1', resource: 'deploymentss', group: 'apps' }, + { version: 'v1', resource: 'configmaps', group: '' }, + ], + }, + ]; + // Mock user settings data useActivePerspectiveMock.mockReturnValue(['dev']); useUserPreferenceMock.mockReturnValue([{}, setPinnedResourcesMock, true]); diff --git a/frontend/packages/console-shared/src/hooks/usePerspectives.ts b/frontend/packages/console-shared/src/hooks/usePerspectives.ts index 3f92a3cec5d..a4c78714dac 100644 --- a/frontend/packages/console-shared/src/hooks/usePerspectives.ts +++ b/frontend/packages/console-shared/src/hooks/usePerspectives.ts @@ -2,42 +2,16 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import type { Perspective as PerspectiveExtension, PerspectiveType, - AccessReviewResourceAttributes, } from '@console/dynamic-plugin-sdk'; import { isPerspective, checkAccess } from '@console/dynamic-plugin-sdk'; import type { LoadedExtension } from '@console/dynamic-plugin-sdk/src/types'; import { useExtensions } from '@console/plugin-sdk/src/api/useExtensions'; import { USER_PREFERENCE_PREFIX } from '../constants/common'; +import type { PerspectiveAccessReview } from '../utils/override-perspectives'; +import { PerspectiveVisibilityState, overridePerspectives } from '../utils/override-perspectives'; const PERSPECTIVE_VISITED_FEATURE_KEY = 'perspective.visited'; -export enum PerspectiveVisibilityState { - Enabled = 'Enabled', - Disabled = 'Disabled', - AccessReview = 'AccessReview', -} - -type PerspectiveAccessReview = { - required?: AccessReviewResourceAttributes[]; - missing?: AccessReviewResourceAttributes[]; -}; - -type PerspectiveVisibility = { - state: PerspectiveVisibilityState; - accessReview?: PerspectiveAccessReview; -}; - -export type PerspectivePinnedResource = { - group: string; - version: string; - resource: string; -}; -export type Perspective = { - id: string; - visibility: PerspectiveVisibility; - pinnedResources?: PerspectivePinnedResource[]; -}; - export const getPerspectiveVisitedKey = (perspective: PerspectiveType): string => `${USER_PREFERENCE_PREFIX}.${PERSPECTIVE_VISITED_FEATURE_KEY}.${perspective}`; @@ -83,7 +57,8 @@ export const usePerspectives = (): LoadedExtension[] => { const perspectiveExtensions = useExtensions(isPerspective); const [results, setResults] = useState>(() => { let obj: Record = {}; - if (!window.SERVER_FLAGS.perspectives) { + + if (!overridePerspectives) { obj = perspectiveExtensions.reduce( (acc: Record, ex: LoadedExtension) => { acc[ex.properties.id] = true; @@ -92,9 +67,10 @@ export const usePerspectives = (): LoadedExtension[] => { {}, ); } else { - const perspectives: Perspective[] = JSON.parse(window.SERVER_FLAGS.perspectives); obj = perspectiveExtensions.reduce((acc, perspectiveExtension) => { - const perspective = perspectives?.find((p) => p.id === perspectiveExtension.properties.id); + const perspective = overridePerspectives.find( + (p) => p.id === perspectiveExtension.properties.id, + ); if ( !perspective?.visibility?.state || @@ -124,11 +100,13 @@ export const usePerspectives = (): LoadedExtension[] => { }, [setResults], ); + useEffect(() => { - if (window.SERVER_FLAGS.perspectives) { - const perspectives: Perspective[] = JSON.parse(window.SERVER_FLAGS.perspectives); + if (overridePerspectives) { perspectiveExtensions.forEach((perspectiveExtension) => { - const perspective = perspectives?.find((p) => p.id === perspectiveExtension.properties.id); + const perspective = overridePerspectives.find( + (p) => p.id === perspectiveExtension.properties.id, + ); if ( !perspective || @@ -156,8 +134,9 @@ export const usePerspectives = (): LoadedExtension[] => { }); } }, [perspectiveExtensions, handleResults]); + const perspectives = useMemo(() => { - if (!window.SERVER_FLAGS.perspectives) { + if (!overridePerspectives) { return perspectiveExtensions; } @@ -168,6 +147,7 @@ export const usePerspectives = (): LoadedExtension[] => { ? perspectiveExtensions.filter((p) => p.properties.id === 'admin') : filteredExtensions; }, [perspectiveExtensions, results]); + return perspectives; }; diff --git a/frontend/packages/console-shared/src/hooks/usePinnedResources.ts b/frontend/packages/console-shared/src/hooks/usePinnedResources.ts index d6d62c8143b..4fc12a98594 100644 --- a/frontend/packages/console-shared/src/hooks/usePinnedResources.ts +++ b/frontend/packages/console-shared/src/hooks/usePinnedResources.ts @@ -3,7 +3,8 @@ import * as _ from 'lodash'; import type { ExtensionK8sModel, K8sModel } from '@console/dynamic-plugin-sdk'; import { useActivePerspective } from '@console/dynamic-plugin-sdk'; import { referenceForExtensionModel, useModelFinder } from '@console/internal/module/k8s'; -import type { Perspective } from './usePerspectives'; +import type { Perspective } from '../utils/override-perspectives'; +import { overridePerspectives } from '../utils/override-perspectives'; import { usePerspectives } from './usePerspectives'; import { useTelemetry } from './useTelemetry'; import { useUserPreference } from './useUserPreference'; @@ -23,9 +24,8 @@ export const usePinnedResources = (): [string[], (pinnedResources: string[]) => const getPins = useCallback( (id: string, defaultPins: ExtensionK8sModel[]): ExtensionK8sModel[] => { let customizedPins: ExtensionK8sModel[] = null; - if (window.SERVER_FLAGS.perspectives) { - const perspectives: Perspective[] = JSON.parse(window.SERVER_FLAGS.perspectives); - const perspective = perspectives.find((p: Perspective) => p.id === id); + if (overridePerspectives) { + const perspective = overridePerspectives.find((p: Perspective) => p.id === id); customizedPins = perspective?.pinnedResources?.map((pr) => { const model: K8sModel = findModel(pr.group, pr.resource); return ( diff --git a/frontend/packages/console-shared/src/utils/override-perspectives.ts b/frontend/packages/console-shared/src/utils/override-perspectives.ts new file mode 100644 index 00000000000..a7735274c2c --- /dev/null +++ b/frontend/packages/console-shared/src/utils/override-perspectives.ts @@ -0,0 +1,56 @@ +import type { AccessReviewResourceAttributes } from '@console/dynamic-plugin-sdk'; + +export enum PerspectiveVisibilityState { + Enabled = 'Enabled', + Disabled = 'Disabled', + AccessReview = 'AccessReview', +} + +export type PerspectiveAccessReview = { + required?: AccessReviewResourceAttributes[]; + missing?: AccessReviewResourceAttributes[]; +}; + +export type PerspectiveVisibility = { + state: PerspectiveVisibilityState; + accessReview?: PerspectiveAccessReview; +}; + +export type PerspectivePinnedResource = { + group?: string; + version: string; + resource: string; +}; + +export type Perspective = { + id: string; + visibility: PerspectiveVisibility; + pinnedResources?: PerspectivePinnedResource[]; +}; + +/** + * If {@link window.SERVER_FLAGS.perspectives} is defined, return the parsed array. + * + * Otherwise, return `undefined` (no perspective overrides specified). + */ +const getOverridePerspectives = (): Perspective[] => { + if (window.SERVER_FLAGS.perspectives) { + try { + const value = JSON.parse(window.SERVER_FLAGS.perspectives); + + if (!Array.isArray(value)) { + throw new Error('Parsed value must be an array', value); + } + + return value as Perspective[]; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Failed to parse perspectives override', e); + } + } + + return undefined; +}; + +// Evaluate once at runtime +export const overridePerspectives = getOverridePerspectives(); diff --git a/frontend/packages/dev-console/src/components/catalog/PinnedResourcesConfiguration.tsx b/frontend/packages/dev-console/src/components/catalog/PinnedResourcesConfiguration.tsx index 72c49a9dab3..a8eca6d3601 100644 --- a/frontend/packages/dev-console/src/components/catalog/PinnedResourcesConfiguration.tsx +++ b/frontend/packages/dev-console/src/components/catalog/PinnedResourcesConfiguration.tsx @@ -28,15 +28,13 @@ import { SaveStatus } from '@console/shared/src/components/cluster-configuration import { useConsoleOperatorConfig } from '@console/shared/src/components/cluster-configuration/useConsoleOperatorConfig'; import { YellowExclamationTriangleIcon } from '@console/shared/src/components/status/icons'; import { useDebounceCallback } from '@console/shared/src/hooks/useDebounceCallback'; +import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import { useTelemetry } from '@console/shared/src/hooks/useTelemetry'; import type { Perspective, PerspectivePinnedResource, -} from '@console/shared/src/hooks/usePerspectives'; -import { - PerspectiveVisibilityState, - usePerspectives, -} from '@console/shared/src/hooks/usePerspectives'; -import { useTelemetry } from '@console/shared/src/hooks/useTelemetry'; +} from '@console/shared/src/utils/override-perspectives'; +import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; import './PinnedResourcesConfiguration.scss'; // skip duplicate resources. diff --git a/pkg/server/server.go b/pkg/server/server.go index b403a5c494a..4519042fb73 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -627,10 +627,20 @@ func (s *Server) HTTPHandler() (http.Handler, error) { Perspectives: []serverconfig.Perspective{}, }, } + if len(s.Perspectives) > 0 { err := json.Unmarshal([]byte(s.Perspectives), &config.Customization.Perspectives) if err != nil { - klog.Errorf("Unable to parse perspective JSON: %v", err) + klog.Errorf("Unable to parse perspectives JSON: %v", err) + } else if len(config.Customization.Perspectives) > 0 { + klog.Infoln("Using console perspective overrides:") + grouped := make(map[string][]string) + for _, perspective := range config.Customization.Perspectives { + grouped[string(perspective.Visibility.State)] = append(grouped[string(perspective.Visibility.State)], perspective.ID) + } + for visibility, ids := range grouped { + klog.Infof(" - [%s]: %s\n", visibility, strings.Join(ids, ", ")) + } } } From 4a15ab296ccbd87f932f70e374027b7c06166a20 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Tue, 30 Jun 2026 17:24:03 +0000 Subject: [PATCH 2/3] Render progress icon when admin perspective is disabled and plugins are still pending --- frontend/@types/console/window.d.ts | 4 +- .../__tests__/pluginTestUtils.ts | 61 +++++-- .../src/components/nav/NavHeader.tsx | 30 +++- .../nav/__tests__/NavHeader.spec.tsx | 149 ++++++++++++++++-- .../components/nav/__tests__/navTestUtils.tsx | 3 + .../src/test-utils/unit-test-utils.tsx | 2 +- .../src/utils/override-perspectives.ts | 2 +- 7 files changed, 218 insertions(+), 33 deletions(-) diff --git a/frontend/@types/console/window.d.ts b/frontend/@types/console/window.d.ts index a0fc0b463ae..bd23e8a67db 100644 --- a/frontend/@types/console/window.d.ts +++ b/frontend/@types/console/window.d.ts @@ -79,9 +79,9 @@ declare interface Window { store?: {}; /** Console plugin store, only available in development builds for debugging */ pluginStore?: {}; - /** Console legacy plugin entry callback, used to load dynamic plugins */ + /** Console legacy plugin entry callback for loading dynamic plugins (4.21 and older) */ loadPluginEntry?: Function; - /** Console plugin entry callback, used to load dynamic plugins */ + /** Console plugin entry callback for loading dynamic plugins (4.22 and newer) */ __load_plugin_entry__?: Function; /** webpack shared scope object */ webpackSharedScope?: {}; diff --git a/frontend/packages/console-app/src/components/console-operator/__tests__/pluginTestUtils.ts b/frontend/packages/console-app/src/components/console-operator/__tests__/pluginTestUtils.ts index 0ebb302be2c..00e5d747618 100644 --- a/frontend/packages/console-app/src/components/console-operator/__tests__/pluginTestUtils.ts +++ b/frontend/packages/console-app/src/components/console-operator/__tests__/pluginTestUtils.ts @@ -1,7 +1,11 @@ import type { - PluginCustomProperties, - PluginLoaderInterface, + Extension, + LoadedExtension, + PluginManifest, + LocalPluginManifest, RemotePluginManifest, + PluginRuntimeMetadata, + PluginLoaderInterface, } from '@openshift/dynamic-plugin-sdk'; import { TestPluginStore } from '@openshift/dynamic-plugin-sdk'; @@ -16,10 +20,18 @@ const noopPluginLoader: PluginLoaderInterface = { }), }; -type PluginManifestOptions = { - version?: string; - customProperties?: PluginCustomProperties; -}; +type PluginManifestOptions = Partial>; + +export const createLocalPluginManifest = ( + name: string, + options: PluginManifestOptions = {}, +): LocalPluginManifest => ({ + name, + version: options.version ?? '1.0.0', + extensions: [], + registrationMethod: 'local', + customProperties: options.customProperties, +}); export const createRemotePluginManifest = ( name: string, @@ -45,27 +57,46 @@ export const createTestPluginStore = ( return pluginStore; }; +const getLoadedExtensions = ( + pluginName: string, + extensions: T[], +): LoadedExtension[] => + extensions.map((e, index) => ({ + ...e, + pluginName, + uid: `${pluginName}[${index}]`, + })); + +export const addLoadedPluginFromManifest = ( + store: TestPluginStore, + manifest: PluginManifest, + extensions: Extension[], + enablePlugin = true, +) => { + store.addLoadedPlugin(manifest, getLoadedExtensions(manifest.name, extensions)); + + if (enablePlugin) { + store.enablePlugins([manifest.name]); + } +}; + export const addLoadedPlugin = ( store: TestPluginStore, name: string, options: PluginManifestOptions = {}, -): void => { - store.addLoadedPlugin(createRemotePluginManifest(name, options), []); +) => { + addLoadedPluginFromManifest(store, createRemotePluginManifest(name, options), []); }; -export const addPendingPlugin = (store: TestPluginStore, name: string): void => { +export const addPendingPlugin = (store: TestPluginStore, name: string) => { store.addPendingPlugin(createRemotePluginManifest(name)); }; -export const addFailedPlugin = ( - store: TestPluginStore, - name: string, - errorMessage: string, -): void => { +export const addFailedPlugin = (store: TestPluginStore, name: string, errorMessage: string) => { store.addFailedPlugin(createRemotePluginManifest(name), errorMessage); }; -export const addLoadedPluginWithoutVersion = (store: TestPluginStore, name: string): void => { +export const addLoadedPluginWithoutVersion = (store: TestPluginStore, name: string) => { const manifest = createRemotePluginManifest(name); delete (manifest as { version?: string }).version; store.addLoadedPlugin(manifest, []); diff --git a/frontend/packages/console-app/src/components/nav/NavHeader.tsx b/frontend/packages/console-app/src/components/nav/NavHeader.tsx index d872b0912b4..67e750eba22 100644 --- a/frontend/packages/console-app/src/components/nav/NavHeader.tsx +++ b/frontend/packages/console-app/src/components/nav/NavHeader.tsx @@ -2,12 +2,17 @@ import type { FC, MouseEvent, Ref } from 'react'; import { useMemo, useState, useCallback } from 'react'; import type { MenuToggleElement } from '@patternfly/react-core'; import { MenuToggle, Select, SelectList, SelectOption, Title } from '@patternfly/react-core'; -import { RhUiGearGroupFillIcon } from '@patternfly/react-icons'; +import { RhUiGearGroupFillIcon, RhUiInProgressIcon } from '@patternfly/react-icons'; import { useTranslation } from 'react-i18next'; import type { Perspective } from '@console/dynamic-plugin-sdk'; import { useActivePerspective } from '@console/dynamic-plugin-sdk'; import { AsyncComponent } from '@console/internal/components/utils/async'; +import { usePluginInfo } from '@console/plugin-sdk/src/api/usePluginInfo'; import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; +import { + PerspectiveVisibilityState, + overridePerspectives, +} from '@console/shared/src/utils/override-perspectives'; type NavHeaderProps = { onPerspectiveSelected: () => void; @@ -50,6 +55,7 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { const [activePerspective, setActivePerspective] = useActivePerspective(); const [isPerspectiveDropdownOpen, setPerspectiveDropdownOpen] = useState(false); const perspectiveExtensions = usePerspectives(); + const pluginInfoEntries = usePluginInfo(); const { t } = useTranslation('console-app'); const togglePerspectiveOpen = useCallback(() => { @@ -90,7 +96,19 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { LoadingComponent={IconLoadingComponent} /> ) : ( - + // Default icon for admin perspective + ); + + const allPluginsProcessed = useMemo( + () => pluginInfoEntries.every((i) => i.status !== 'pending'), + [pluginInfoEntries], + ); + + const activePerspectiveDisabled = useMemo( + () => + overridePerspectives?.find((p) => p.id === activePerspective)?.visibility?.state === + PerspectiveVisibilityState.Disabled, + [activePerspective], ); return perspectiveDropdownItems.length > 1 ? ( @@ -128,7 +146,13 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { ) : (
- {ActivePerspectiveIcon} {name} + {!allPluginsProcessed && activePerspective === 'admin' && activePerspectiveDisabled ? ( + <RhUiInProgressIcon data-test="perspective-progress-icon" /> + ) : ( + <> + {ActivePerspectiveIcon} {name} + </> + )}
); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx index de9833abfe3..4799ff27d5e 100644 --- a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx @@ -1,8 +1,14 @@ +import { TestPluginStore } from '@openshift/dynamic-plugin-sdk'; import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; import type { Perspective } from '@console/shared/src/utils/override-perspectives'; import { PerspectiveVisibilityState } from '@console/shared/src/utils/override-perspectives'; +import { + createLocalPluginManifest, + createRemotePluginManifest, + addLoadedPluginFromManifest, +} from '../../console-operator/__tests__/pluginTestUtils'; import NavHeader from '../NavHeader'; import { renderWithPerspective } from './navTestUtils'; @@ -19,20 +25,44 @@ jest.mock('@console/internal/components/utils/async', () => ({ AsyncComponent: () => null, })); +// Minimal PluginStore test impl. containing Console perspective extensions. +// TODO: replace with `new TestPluginStore(actualPluginStore)` once supported +const createPluginStoreWithPerspectiveExtensions = () => { + const pluginStore = new TestPluginStore(); + + addLoadedPluginFromManifest(pluginStore, createLocalPluginManifest('@console/app'), [ + { + type: 'console.perspective', + properties: { id: 'admin', default: true, name: 'Core platform' }, + }, + ]); + + addLoadedPluginFromManifest(pluginStore, createLocalPluginManifest('@console/dev-console'), [ + { + type: 'console.perspective', + properties: { id: 'dev', name: 'Developer' }, + }, + ]); + + return pluginStore; +}; + describe('NavHeader', () => { const mockOnPerspectiveSelected = jest.fn(); let mockSetActivePerspective: jest.Mock; + let pluginStore: TestPluginStore; beforeEach(() => { jest.clearAllMocks(); mockSetActivePerspective = jest.fn(); + pluginStore = createPluginStoreWithPerspectiveExtensions(); }); - // Uses real perspectives from static plugin data loaded via renderWithProviders - // Static plugins provide admin and dev perspectives by default describe('when multiple perspectives are available', () => { it('should render perspective switcher dropdown with toggle button', () => { - renderWithProviders(); + renderWithProviders(, { + pluginStore, + }); const toggle = screen.getByRole('button', { expanded: false }); expect(toggle).toBeVisible(); @@ -41,7 +71,9 @@ describe('NavHeader', () => { it('should open dropdown menu when toggle is clicked', async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(, { + pluginStore, + }); const toggle = screen.getByRole('button', { expanded: false }); await user.click(toggle); @@ -51,7 +83,9 @@ describe('NavHeader', () => { it('should display all perspective options in dropdown menu', async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(, { + pluginStore, + }); await user.click(screen.getByRole('button')); @@ -65,6 +99,7 @@ describe('NavHeader', () => { , 'admin', mockSetActivePerspective, + { pluginStore }, ); await user.click(screen.getByRole('button')); @@ -84,6 +119,7 @@ describe('NavHeader', () => { , 'admin', mockSetActivePerspective, + { pluginStore }, ); await user.click(screen.getByRole('button')); @@ -98,7 +134,7 @@ describe('NavHeader', () => { }); }); - describe('when only one perspective is available', () => { + describe('when only admin perspective is available', () => { beforeEach(() => { mockOverridePerspectives = [ { id: 'admin', visibility: { state: PerspectiveVisibilityState.Enabled } }, @@ -110,15 +146,17 @@ describe('NavHeader', () => { mockOverridePerspectives = undefined; }); - it('should render static label instead of dropdown', () => { - renderWithProviders(); + it('should render static label instead of a dropdown', () => { + renderWithProviders(, { + pluginStore, + }); expect(screen.getByRole('heading', { name: 'Core platform' })).toBeVisible(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); }); - describe('when all perspectives are disabled', () => { + describe('when both admin and dev perspectives are unavailable', () => { beforeEach(() => { mockOverridePerspectives = [ { id: 'admin', visibility: { state: PerspectiveVisibilityState.Disabled } }, @@ -130,11 +168,100 @@ describe('NavHeader', () => { mockOverridePerspectives = undefined; }); - it('should fall back to static label for admin perspective', () => { - renderWithProviders(); + it('active perspective "admin", no remote plugins: render admin perspective label', () => { + renderWithPerspective( + , + 'admin', + mockSetActivePerspective, + { pluginStore }, + ); expect(screen.getByRole('heading', { name: 'Core platform' })).toBeVisible(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('perspective-progress-icon')).not.toBeInTheDocument(); + }); + + it('active perspective "admin", remote plugins pending: render in-progress icon', () => { + pluginStore.addPendingPlugin(createRemotePluginManifest('test-plugin')); + + renderWithPerspective( + , + 'admin', + mockSetActivePerspective, + { pluginStore }, + ); + + expect(screen.queryByRole('heading', { name: 'Core platform' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByTestId('perspective-progress-icon')).toBeVisible(); + }); + + it('active perspective "admin", remote plugins loaded: render test perspective label', () => { + addLoadedPluginFromManifest(pluginStore, createRemotePluginManifest('test-plugin'), [ + { + type: 'console.perspective', + properties: { id: 'test', name: 'Test perspective name' }, + }, + ]); + + renderWithPerspective( + , + 'admin', + mockSetActivePerspective, + { pluginStore }, + ); + + expect(screen.getByRole('heading', { name: 'Test perspective name' })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('perspective-progress-icon')).not.toBeInTheDocument(); + }); + + it('active perspective "test", no remote plugins: render admin perspective label', () => { + renderWithPerspective( + , + 'test', + mockSetActivePerspective, + { pluginStore }, + ); + + expect(screen.getByRole('heading', { name: 'Core platform' })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('perspective-progress-icon')).not.toBeInTheDocument(); + }); + + it('active perspective "test", remote plugins pending: render admin perspective label', () => { + pluginStore.addPendingPlugin(createRemotePluginManifest('test-plugin')); + + renderWithPerspective( + , + 'test', + mockSetActivePerspective, + { pluginStore }, + ); + + expect(screen.getByRole('heading', { name: 'Core platform' })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('perspective-progress-icon')).not.toBeInTheDocument(); + }); + + it('active perspective "test", remote plugins loaded: render test perspective label', () => { + addLoadedPluginFromManifest(pluginStore, createRemotePluginManifest('test-plugin'), [ + { + type: 'console.perspective', + properties: { id: 'test', name: 'Test perspective name' }, + }, + ]); + + renderWithPerspective( + , + 'test', + mockSetActivePerspective, + { pluginStore }, + ); + + expect(screen.getByRole('heading', { name: 'Test perspective name' })).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('perspective-progress-icon')).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx b/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx index 0e62a67f779..7409352c8d1 100644 --- a/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx +++ b/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx @@ -1,15 +1,18 @@ import type { ReactElement } from 'react'; import type { PerspectiveType } from '@console/dynamic-plugin-sdk'; import { PerspectiveContext } from '@console/dynamic-plugin-sdk'; +import type { ExtendedRenderOptions } from '@console/shared/src/test-utils/unit-test-utils'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; export const renderWithPerspective = ( ui: ReactElement, activePerspective: PerspectiveType = 'admin', setActivePerspective: jest.Mock = jest.fn(), + options?: ExtendedRenderOptions, ) => renderWithProviders( {ui} , + options, ); diff --git a/frontend/packages/console-shared/src/test-utils/unit-test-utils.tsx b/frontend/packages/console-shared/src/test-utils/unit-test-utils.tsx index 093b21ce2f0..7c962479ef9 100644 --- a/frontend/packages/console-shared/src/test-utils/unit-test-utils.tsx +++ b/frontend/packages/console-shared/src/test-utils/unit-test-utils.tsx @@ -15,7 +15,7 @@ import { pluginStore as defaultPluginStore } from '@console/internal/plugins'; import type { RootState } from '@console/internal/redux'; import { baseReducers } from '@console/internal/redux'; -interface ExtendedRenderOptions extends Omit { +export interface ExtendedRenderOptions extends Omit { initialState?: Partial; store?: ReturnType; pluginStore?: PluginStore; diff --git a/frontend/packages/console-shared/src/utils/override-perspectives.ts b/frontend/packages/console-shared/src/utils/override-perspectives.ts index a7735274c2c..972561f8b75 100644 --- a/frontend/packages/console-shared/src/utils/override-perspectives.ts +++ b/frontend/packages/console-shared/src/utils/override-perspectives.ts @@ -33,7 +33,7 @@ export type Perspective = { * * Otherwise, return `undefined` (no perspective overrides specified). */ -const getOverridePerspectives = (): Perspective[] => { +const getOverridePerspectives = (): Perspective[] | undefined => { if (window.SERVER_FLAGS.perspectives) { try { const value = JSON.parse(window.SERVER_FLAGS.perspectives); From 7d9aea38a5c852fbdfbc3efa78944461bb03219f Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Tue, 30 Jun 2026 18:40:30 +0000 Subject: [PATCH 3/3] Avoid warnings due to local pluginStore recreation --- .../src/components/nav/__tests__/NavHeader.spec.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx index 4799ff27d5e..a94099c699b 100644 --- a/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx +++ b/frontend/packages/console-app/src/components/nav/__tests__/NavHeader.spec.tsx @@ -28,7 +28,9 @@ jest.mock('@console/internal/components/utils/async', () => ({ // Minimal PluginStore test impl. containing Console perspective extensions. // TODO: replace with `new TestPluginStore(actualPluginStore)` once supported const createPluginStoreWithPerspectiveExtensions = () => { - const pluginStore = new TestPluginStore(); + const pluginStore = new TestPluginStore({ + loaderOptions: { entryCallbackSettings: { registerCallback: false } }, + }); addLoadedPluginFromManifest(pluginStore, createLocalPluginManifest('@console/app'), [ {