From b4283a6ba8916c35aba0d50619cf9918e817c761 Mon Sep 17 00:00:00 2001 From: Ugo Palatucci Date: Tue, 16 Jun 2026 15:15:51 +0200 Subject: [PATCH] Add console.force-perspective extension for single-perspective mode Introduces a plugin extension and hook to force one active perspective, persist the choice in localStorage across reloads, and reduce nav/context re-render churn while plugins resolve. Co-authored-by: Cursor --- .../detect-context/DetectContext.tsx | 49 ++++- .../useValuesForPerspectiveContext.spec.ts | 30 ++- .../useValuesForPerspectiveContext.ts | 19 +- .../src/components/nav/NavHeader.tsx | 62 ++++-- .../src/components/nav/PerspectiveNav.tsx | 15 +- .../nav/__tests__/NavHeader.spec.tsx | 5 + .../components/nav/__tests__/navTestUtils.tsx | 9 +- .../docs/console-extensions.md | 110 +++++----- .../src/extensions/force-perspective.ts | 15 ++ .../src/extensions/index.ts | 1 + .../__tests__/useForcedPerspective.spec.ts | 199 ++++++++++++++++++ .../src/hooks/forcedPerspectiveContext.ts | 15 ++ .../src/hooks/useForcedPerspective.ts | 96 +++++++++ .../src/hooks/usePerspectives.ts | 25 ++- .../utils/__tests__/forcedPerspective.spec.ts | 61 ++++++ .../src/utils/forcedPerspective.ts | 60 ++++++ 16 files changed, 660 insertions(+), 111 deletions(-) create mode 100644 frontend/packages/console-dynamic-plugin-sdk/src/extensions/force-perspective.ts create mode 100644 frontend/packages/console-shared/src/hooks/__tests__/useForcedPerspective.spec.ts create mode 100644 frontend/packages/console-shared/src/hooks/forcedPerspectiveContext.ts create mode 100644 frontend/packages/console-shared/src/hooks/useForcedPerspective.ts create mode 100644 frontend/packages/console-shared/src/utils/__tests__/forcedPerspective.spec.ts create mode 100644 frontend/packages/console-shared/src/utils/forcedPerspective.ts diff --git a/frontend/packages/console-app/src/components/detect-context/DetectContext.tsx b/frontend/packages/console-app/src/components/detect-context/DetectContext.tsx index 990432f29c4..4d530138ea6 100644 --- a/frontend/packages/console-app/src/components/detect-context/DetectContext.tsx +++ b/frontend/packages/console-app/src/components/detect-context/DetectContext.tsx @@ -1,5 +1,5 @@ import type { FC, Provider as ProviderComponent, ReactNode } from 'react'; -import { createContext, Suspense, useContext, useEffect } from 'react'; +import { createContext, Suspense, useContext, useEffect, useMemo } from 'react'; import type { LoadedAndResolvedExtension } from '@openshift/dynamic-plugin-sdk'; import { Button, @@ -24,6 +24,8 @@ import { } from '@console/dynamic-plugin-sdk'; import { applyReduxExtensions } from '@console/internal/redux'; import { LoadingBox } from '@console/shared/src/components/loading/LoadingBox'; +import { ForcedPerspectiveContext } from '@console/shared/src/hooks/forcedPerspectiveContext'; +import { useForcedPerspective } from '@console/shared/src/hooks/useForcedPerspective'; import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; import { useLanguage } from '../user-preferences/language/useLanguage'; import { usePreferredLanguage } from '../user-preferences/language/usePreferredLanguage'; @@ -148,11 +150,12 @@ export const ContextProviderExtensionWrapper: FC<{ children: ReactNode }> = ({ c * NamespaceContext, and ContextProviderExtensionsContext. */ export const DetectContext: FC<{ children: ReactNode }> = ({ children }) => { + const forcedPerspective = useForcedPerspective(); const [ activePerspective, setActivePerspective, perspectiveLoaded, - ] = useValuesForPerspectiveContext(); + ] = useValuesForPerspectiveContext(forcedPerspective); const { namespace, setNamespace, loaded: namespaceLoaded } = useValuesForNamespaceContext(); const [preferredLanguage, , preferredLanguageLoaded] = usePreferredLanguage(); @@ -170,10 +173,22 @@ export const DetectContext: FC<{ children: ReactNode }> = ({ children }) => { const location = useLocation(); useEffect(() => { + if (forcedPerspective.perspectiveId) { + if (forcedPerspective.perspectiveId !== activePerspective) { + setActivePerspective(forcedPerspective.perspectiveId, createPath(location)); + } + return; + } if (perspectiveParam && perspectiveParam !== activePerspective) { setActivePerspective(perspectiveParam, createPath(location)); } - }, [perspectiveParam, activePerspective, setActivePerspective, location]); + }, [ + forcedPerspective.perspectiveId, + perspectiveParam, + activePerspective, + setActivePerspective, + location, + ]); useEffect(() => { if (reducersResolved) { @@ -181,7 +196,8 @@ export const DetectContext: FC<{ children: ReactNode }> = ({ children }) => { } }, [reducersResolved, reduxReducerExtensions]); - const needsPerspectiveDetection = perspectiveLoaded && !activePerspective; + const needsPerspectiveDetection = + perspectiveLoaded && !activePerspective && !forcedPerspective.perspectiveId; const ready = perspectiveLoaded && !!activePerspective && @@ -190,6 +206,15 @@ export const DetectContext: FC<{ children: ReactNode }> = ({ children }) => { providersResolved && preferredLanguageLoaded; + const perspectiveContextValue = useMemo(() => ({ activePerspective, setActivePerspective }), [ + activePerspective, + setActivePerspective, + ]); + const namespaceContextValue = useMemo(() => ({ namespace, setNamespace }), [ + namespace, + setNamespace, + ]); + if (!ready) { const pending: string[] = []; if (!perspectiveLoaded) pending.push('Perspective'); @@ -210,12 +235,14 @@ export const DetectContext: FC<{ children: ReactNode }> = ({ children }) => { } return ( - - - - {children} - - - + + + + + {children} + + + + ); }; diff --git a/frontend/packages/console-app/src/components/detect-context/__tests__/useValuesForPerspectiveContext.spec.ts b/frontend/packages/console-app/src/components/detect-context/__tests__/useValuesForPerspectiveContext.spec.ts index c24e8df35b6..0839e6a254e 100644 --- a/frontend/packages/console-app/src/components/detect-context/__tests__/useValuesForPerspectiveContext.spec.ts +++ b/frontend/packages/console-app/src/components/detect-context/__tests__/useValuesForPerspectiveContext.spec.ts @@ -12,6 +12,8 @@ import { usePreferredPerspective } from '../../user-preferences/perspective/useP import { useLastPerspective } from '../useLastPerspective'; import { useValuesForPerspectiveContext } from '../useValuesForPerspectiveContext'; +const noForcedPerspective = { loaded: true, perspectiveId: null }; + jest.mock('@console/shared/src/hooks/usePerspectives', () => ({ usePerspectiveExtension: jest.fn(), usePerspectives: jest.fn(), @@ -44,7 +46,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['foo', jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue([undefined, jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - const { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + const { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); const [perspective] = result.current; expect(perspective).toBe(''); }); @@ -54,7 +58,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['dev', jest.fn(), false]); usePreferredPerspectiveMock.mockReturnValue(['admin', jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - let { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + let { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); let [perspective, , loaded] = result.current; expect(perspective).toBe(''); expect(loaded).toBeFalsy(); @@ -65,7 +71,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['dev', jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue(['admin', jest.fn(), false]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - ({ result } = renderHookWithProviders(() => useValuesForPerspectiveContext())); + ({ result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + )); [perspective, , loaded] = result.current; expect(perspective).toBe(''); expect(loaded).toBeFalsy(); @@ -76,7 +84,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['dev', jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue(['admin', jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - const { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + const { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); const [perspective] = result.current; expect(perspective).toEqual('admin'); }); @@ -86,7 +96,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['dev', jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue([undefined, jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - const { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + const { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); const [perspective] = result.current; expect(perspective).toEqual('dev'); }); @@ -96,7 +108,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue(['dev', jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue(['dev', jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - const { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + const { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); const [perspective] = result.current; expect(perspective).toEqual('dev'); }); @@ -106,7 +120,9 @@ describe('useValuesForPerspectiveContext', () => { useLastPerspectiveMock.mockReturnValue([undefined, jest.fn(), true]); usePreferredPerspectiveMock.mockReturnValue([undefined, jest.fn(), true]); usePerspectiveExtensionMock.mockReturnValue(acmPerspectiveExtension); - const { result } = renderHookWithProviders(() => useValuesForPerspectiveContext()); + const { result } = renderHookWithProviders(() => + useValuesForPerspectiveContext(noForcedPerspective), + ); const [perspective] = result.current; expect(perspective).toEqual(ACM_PERSPECTIVE_ID); }); diff --git a/frontend/packages/console-app/src/components/detect-context/useValuesForPerspectiveContext.ts b/frontend/packages/console-app/src/components/detect-context/useValuesForPerspectiveContext.ts index 4be72bae345..cbfac4defab 100644 --- a/frontend/packages/console-app/src/components/detect-context/useValuesForPerspectiveContext.ts +++ b/frontend/packages/console-app/src/components/detect-context/useValuesForPerspectiveContext.ts @@ -6,17 +6,16 @@ import { usePerspectives, } from '@console/shared/src/hooks/usePerspectives'; import { useTelemetry } from '@console/shared/src/hooks/useTelemetry'; +import type { ForcedPerspectiveResult } from '@console/shared/src/utils/forcedPerspective'; import { ACM_PERSPECTIVE_ID } from '../../consts'; import { usePreferredPerspective } from '../user-preferences/perspective/usePreferredPerspective'; import { useLastPerspective } from './useLastPerspective'; type SetActivePerspective = ReturnType[1]; -export const useValuesForPerspectiveContext = (): [ - PerspectiveType, - SetActivePerspective, - boolean, -] => { +export const useValuesForPerspectiveContext = ( + forcedPerspective: ForcedPerspectiveResult, +): [PerspectiveType, SetActivePerspective, boolean] => { const navigate = useNavigate(); const fireTelemetryEvent = useTelemetry(); const perspectiveExtensions = usePerspectives(); @@ -32,7 +31,13 @@ export const useValuesForPerspectiveContext = (): [ ? ACM_PERSPECTIVE_ID : existingPerspective || ''; const isValidPerspective = - loaded && perspectiveExtensions.some((p) => p.properties.id === perspective); + (loaded && perspectiveExtensions.some((p) => p.properties.id === perspective)) || + !!forcedPerspective.perspectiveId; + const resolvedPerspective = forcedPerspective.perspectiveId + ? forcedPerspective.perspectiveId + : isValidPerspective + ? perspective + : ''; const setPerspective = useCallback( (newPerspective, next) => { @@ -45,5 +50,5 @@ export const useValuesForPerspectiveContext = (): [ [setLastPerspective, setActivePerspective, navigate, fireTelemetryEvent], ); - return [isValidPerspective ? perspective : '', setPerspective, loaded]; + return [resolvedPerspective, setPerspective, loaded]; }; diff --git a/frontend/packages/console-app/src/components/nav/NavHeader.tsx b/frontend/packages/console-app/src/components/nav/NavHeader.tsx index 444d7550abc..ad2f92efb4e 100644 --- a/frontend/packages/console-app/src/components/nav/NavHeader.tsx +++ b/frontend/packages/console-app/src/components/nav/NavHeader.tsx @@ -1,12 +1,18 @@ 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 { useTranslation } from 'react-i18next'; +import { + MenuToggle, + Select, + SelectList, + SelectOption, + Title, + Skeleton, +} from '@patternfly/react-core'; import type { Perspective } from '@console/dynamic-plugin-sdk'; import { useActivePerspective } from '@console/dynamic-plugin-sdk'; import { AsyncComponent } from '@console/internal/components/utils/async'; +import { useForcedPerspectiveContext } from '@console/shared/src/hooks/forcedPerspectiveContext'; import { usePerspectives } from '@console/shared/src/hooks/usePerspectives'; type NavHeaderProps = { @@ -22,6 +28,18 @@ type PerspectiveDropdownItemProps = { const IconLoadingComponent: FC = () => <> ; +const PerspectiveIcon: FC<{ + icon?: Perspective['properties']['icon']; +}> = ({ icon }) => + icon ? ( + icon().then((m) => m.default)} + LoadingComponent={IconLoadingComponent} + /> + ) : ( + + ); + const PerspectiveDropdownItem: FC = ({ perspective, onClick }) => { return ( = ({ perspective e.preventDefault(); onClick(perspective.properties.id); }} - icon={ - perspective.properties.icon().then((m) => m.default)} - LoadingComponent={IconLoadingComponent} - /> - } + icon={} > {perspective.properties.name} @@ -49,7 +62,13 @@ const NavHeader: FC<NavHeaderProps> = ({ onPerspectiveSelected }) => { const [activePerspective, setActivePerspective] = useActivePerspective(); const [isPerspectiveDropdownOpen, setPerspectiveDropdownOpen] = useState(false); const perspectiveExtensions = usePerspectives(); - const { t } = useTranslation('console-app'); + const forcedPerspective = useForcedPerspectiveContext(); + const displayedPerspective = useMemo(() => { + const targetId = forcedPerspective.perspectiveId || activePerspective; + return ( + perspectiveExtensions.find((p) => p?.properties?.id === targetId) ?? perspectiveExtensions[0] + ); + }, [forcedPerspective.perspectiveId, activePerspective, perspectiveExtensions]); const togglePerspectiveOpen = useCallback(() => { setPerspectiveDropdownOpen((isOpen) => !isOpen); @@ -80,7 +99,7 @@ const NavHeader: FC<NavHeaderProps> = ({ onPerspectiveSelected }) => { [activePerspective, perspectiveExtensions], ); - return perspectiveDropdownItems.length > 1 ? ( + return perspectiveDropdownItems.length > 1 && !forcedPerspective.perspectiveId ? ( <div className="oc-nav-header" data-tour-id="tour-perspective-dropdown" @@ -97,14 +116,7 @@ const NavHeader: FC<NavHeaderProps> = ({ onPerspectiveSelected }) => { isExpanded={isPerspectiveDropdownOpen} ref={toggleRef} onClick={() => togglePerspectiveOpen()} - icon={ - icon && ( - <AsyncComponent - loader={() => icon().then((m) => m.default)} - LoadingComponent={IconLoadingComponent} - /> - ) - } + icon={<PerspectiveIcon icon={icon ?? undefined} />} > {name && ( <Title headingLevel="h2" size="md"> @@ -121,9 +133,17 @@ const NavHeader: FC<NavHeaderProps> = ({ onPerspectiveSelected }) => { </Select> </div> ) : ( - <div data-test-id="perspective-switcher-toggle" id="core-platform-perspective"> + <div + data-test-id="perspective-switcher-toggle" + id={ + forcedPerspective.perspectiveId || + displayedPerspective?.properties?.id || + 'core-platform-perspective' + } + > <Title headingLevel="h2" size="md"> - <RhUiGearGroupFillIcon /> {t('Core platform')} + <PerspectiveIcon icon={displayedPerspective?.properties?.icon} />{' '} + {displayedPerspective?.properties?.name ?? <Skeleton />} ); diff --git a/frontend/packages/console-app/src/components/nav/PerspectiveNav.tsx b/frontend/packages/console-app/src/components/nav/PerspectiveNav.tsx index 45e324e1c1d..31116fc6f90 100644 --- a/frontend/packages/console-app/src/components/nav/PerspectiveNav.tsx +++ b/frontend/packages/console-app/src/components/nav/PerspectiveNav.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import { useCallback, useState, useEffect, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { NavList } from '@patternfly/react-core'; import type { DragDropSortProps, DraggableObject } from '@patternfly/react-drag-drop'; import { DragDropSort } from '@patternfly/react-drag-drop'; @@ -18,18 +18,15 @@ import { getSortedNavExtensions, isTopLevelNavItem } from './utils'; import './PerspectiveNav.scss'; -const PerspectiveNav: FC<{}> = () => { +const PerspectiveNav: FC = () => { const [activePerspective] = useActivePerspective(); const allNavExtensions = useNavExtensionsForPerspective(activePerspective); const [pinnedResources, setPinnedResources, pinnedResourcesLoaded] = usePinnedResources(); - const [validPinnedResources, setValidPinnedResources] = useState([]); + const validPinnedResources = useMemo(() => pinnedResources.filter((res) => !!modelFor(res)), [ + pinnedResources, + ]); const { t } = useTranslation('console-app'); - useEffect(() => { - const validResources = pinnedResources.filter((res) => !!modelFor(res)); - setValidPinnedResources(validResources); - }, [setValidPinnedResources, pinnedResources]); - const orderedNavExtensions = useMemo(() => { const topLevelNavExtensions = allNavExtensions.filter(isTopLevelNavItem); return getSortedNavExtensions(topLevelNavExtensions); @@ -98,4 +95,4 @@ const PerspectiveNav: FC<{}> = () => { ); }; -export default PerspectiveNav; +export default memo(PerspectiveNav); 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..38802370d2c 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 @@ -8,11 +8,16 @@ jest.mock('@console/internal/components/utils/async', () => ({ AsyncComponent: () => null, })); +jest.mock('@console/shared/src/hooks/forcedPerspectiveContext', () => ({ + useForcedPerspectiveContext: jest.fn(() => ({ loaded: true, perspectiveId: null })), +})); + describe('NavHeader', () => { const mockOnPerspectiveSelected = jest.fn(); let mockSetActivePerspective: jest.Mock; beforeEach(() => { + window.localStorage.clear(); jest.clearAllMocks(); mockSetActivePerspective = jest.fn(); }); 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..e53e6d54941 100644 --- a/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx +++ b/frontend/packages/console-app/src/components/nav/__tests__/navTestUtils.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import type { PerspectiveType } from '@console/dynamic-plugin-sdk'; import { PerspectiveContext } from '@console/dynamic-plugin-sdk'; +import { ForcedPerspectiveContext } from '@console/shared/src/hooks/forcedPerspectiveContext'; import { renderWithProviders } from '@console/shared/src/test-utils/unit-test-utils'; export const renderWithPerspective = ( @@ -9,7 +10,9 @@ export const renderWithPerspective = ( setActivePerspective: jest.Mock = jest.fn(), ) => renderWithProviders( - - {ui} - , + + + {ui} + + , ); diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md index 9db323bfa58..893ef68f071 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md @@ -33,53 +33,54 @@ 31. [console.flag](#consoleflag) 32. [console.flag/hookProvider](#consoleflaghookProvider) 33. [console.flag/model](#consoleflagmodel) -34. [console.global-config](#consoleglobal-config) -35. [console.model-metadata](#consolemodel-metadata) -36. [console.navigation/href](#consolenavigationhref) -37. [console.navigation/resource-cluster](#consolenavigationresource-cluster) -38. [console.navigation/resource-ns](#consolenavigationresource-ns) -39. [console.navigation/section](#consolenavigationsection) -40. [console.navigation/separator](#consolenavigationseparator) -41. [console.node/status](#consolenodestatus) -42. [console.node/sub-nav-tab](#consolenodesub-nav-tab) -43. [console.page/resource/details](#consolepageresourcedetails) -44. [console.page/resource/list](#consolepageresourcelist) -45. [console.page/route](#consolepageroute) -46. [console.page/route/standalone](#consolepageroutestandalone) -47. [console.perspective](#consoleperspective) -48. [console.project-overview/inventory-item](#consoleproject-overviewinventory-item) -49. [console.project-overview/utilization-item](#consoleproject-overviewutilization-item) -50. [console.pvc/alert](#consolepvcalert) -51. [console.pvc/create-prop](#consolepvccreate-prop) -52. [console.pvc/delete](#consolepvcdelete) -53. [console.pvc/status](#consolepvcstatus) -54. [console.redux-reducer](#consoleredux-reducer) -55. [console.resource/create](#consoleresourcecreate) -56. [console.resource/details-item](#consoleresourcedetails-item) -57. [console.storage-class/provisioner](#consolestorage-classprovisioner) -58. [console.storage-provider](#consolestorage-provider) -59. [console.tab](#consoletab) -60. [console.tab/horizontalNav](#consoletabhorizontalNav) -61. [console.telemetry/listener](#consoletelemetrylistener) -62. [console.topology/adapter/build](#consoletopologyadapterbuild) -63. [console.topology/adapter/network](#consoletopologyadapternetwork) -64. [console.topology/adapter/pod](#consoletopologyadapterpod) -65. [console.topology/component/factory](#consoletopologycomponentfactory) -66. [console.topology/create/connector](#consoletopologycreateconnector) -67. [console.topology/data/factory](#consoletopologydatafactory) -68. [console.topology/decorator/provider](#consoletopologydecoratorprovider) -69. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) -70. [console.topology/details/resource-link](#consoletopologydetailsresource-link) -71. [console.topology/details/tab](#consoletopologydetailstab) -72. [console.topology/details/tab-section](#consoletopologydetailstab-section) -73. [console.topology/display/filters](#consoletopologydisplayfilters) -74. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) -75. [console.user-preference/group](#consoleuser-preferencegroup) -76. [console.user-preference/item](#consoleuser-preferenceitem) -77. [console.yaml-template](#consoleyaml-template) -78. [dev-console.add/action](#dev-consoleaddaction) -79. [dev-console.add/action-group](#dev-consoleaddaction-group) -80. [dev-console.import/environment](#dev-consoleimportenvironment) +34. [console.force-perspective](#consoleforce-perspective) +35. [console.global-config](#consoleglobal-config) +36. [console.model-metadata](#consolemodel-metadata) +37. [console.navigation/href](#consolenavigationhref) +38. [console.navigation/resource-cluster](#consolenavigationresource-cluster) +39. [console.navigation/resource-ns](#consolenavigationresource-ns) +40. [console.navigation/section](#consolenavigationsection) +41. [console.navigation/separator](#consolenavigationseparator) +42. [console.node/status](#consolenodestatus) +43. [console.node/sub-nav-tab](#consolenodesub-nav-tab) +44. [console.page/resource/details](#consolepageresourcedetails) +45. [console.page/resource/list](#consolepageresourcelist) +46. [console.page/route](#consolepageroute) +47. [console.page/route/standalone](#consolepageroutestandalone) +48. [console.perspective](#consoleperspective) +49. [console.project-overview/inventory-item](#consoleproject-overviewinventory-item) +50. [console.project-overview/utilization-item](#consoleproject-overviewutilization-item) +51. [console.pvc/alert](#consolepvcalert) +52. [console.pvc/create-prop](#consolepvccreate-prop) +53. [console.pvc/delete](#consolepvcdelete) +54. [console.pvc/status](#consolepvcstatus) +55. [console.redux-reducer](#consoleredux-reducer) +56. [console.resource/create](#consoleresourcecreate) +57. [console.resource/details-item](#consoleresourcedetails-item) +58. [console.storage-class/provisioner](#consolestorage-classprovisioner) +59. [console.storage-provider](#consolestorage-provider) +60. [console.tab](#consoletab) +61. [console.tab/horizontalNav](#consoletabhorizontalNav) +62. [console.telemetry/listener](#consoletelemetrylistener) +63. [console.topology/adapter/build](#consoletopologyadapterbuild) +64. [console.topology/adapter/network](#consoletopologyadapternetwork) +65. [console.topology/adapter/pod](#consoletopologyadapterpod) +66. [console.topology/component/factory](#consoletopologycomponentfactory) +67. [console.topology/create/connector](#consoletopologycreateconnector) +68. [console.topology/data/factory](#consoletopologydatafactory) +69. [console.topology/decorator/provider](#consoletopologydecoratorprovider) +70. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) +71. [console.topology/details/resource-link](#consoletopologydetailsresource-link) +72. [console.topology/details/tab](#consoletopologydetailstab) +73. [console.topology/details/tab-section](#consoletopologydetailstab-section) +74. [console.topology/display/filters](#consoletopologydisplayfilters) +75. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) +76. [console.user-preference/group](#consoleuser-preferencegroup) +77. [console.user-preference/item](#consoleuser-preferenceitem) +78. [console.yaml-template](#consoleyaml-template) +79. [dev-console.add/action](#dev-consoleaddaction) +80. [dev-console.add/action-group](#dev-consoleaddaction-group) +81. [dev-console.import/environment](#dev-consoleimportenvironment) --- @@ -627,6 +628,21 @@ Adds new Console feature flag driven by the presence of a CRD on the cluster. --- +## `console.force-perspective` + +### Summary + +Forces a single perspective to be active and hides the perspective switcher dropdown. + +### Properties + +| Name | Value Type | Optional | Description | +| ---- | ---------- | -------- | ----------- | +| `perspectiveId` | `string` | no | The perspective identifier to force. | +| `useForcePerspective` | `CodeRef<() => [boolean, boolean]>` | no | Hook that returns `[shouldForce, loading]`. | + +--- + ## `console.global-config` ### Summary diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/force-perspective.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/force-perspective.ts new file mode 100644 index 00000000000..644bf3708b1 --- /dev/null +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/force-perspective.ts @@ -0,0 +1,15 @@ +import type { Extension, CodeRef } from '../types'; + +/** Forces a single perspective to be active and hides the perspective switcher. */ +export type ForcePerspective = Extension< + 'console.force-perspective', + { + /** The perspective identifier to force. */ + perspectiveId: string; + /** Hook that returns [shouldForce, loading]. */ + useForcePerspective: CodeRef<() => [boolean, boolean]>; + } +>; + +export const isForcePerspective = (e: Extension): e is ForcePerspective => + e.type === 'console.force-perspective'; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts index c35e66927e1..3aea62233b4 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/index.ts @@ -14,6 +14,7 @@ export * from './dashboard-types'; export * from './dashboards'; export * from './details-item'; export * from './feature-flags'; +export * from './force-perspective'; export * from './file-upload'; export * from './horizontal-nav-tabs'; export * from './import-environments'; diff --git a/frontend/packages/console-shared/src/hooks/__tests__/useForcedPerspective.spec.ts b/frontend/packages/console-shared/src/hooks/__tests__/useForcedPerspective.spec.ts new file mode 100644 index 00000000000..7f0cb4dfbb0 --- /dev/null +++ b/frontend/packages/console-shared/src/hooks/__tests__/useForcedPerspective.spec.ts @@ -0,0 +1,199 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { isForcePerspective, useResolvedExtensions } from '@console/dynamic-plugin-sdk'; +import { FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY } from '../../utils/forcedPerspective'; +import { useForcedPerspective } from '../useForcedPerspective'; + +jest.mock('@console/dynamic-plugin-sdk', () => ({ + isForcePerspective: jest.fn(), + useResolvedExtensions: jest.fn(), +})); + +const useResolvedExtensionsMock = useResolvedExtensions as jest.Mock; + +describe('useForcedPerspective', () => { + beforeEach(() => { + window.localStorage.clear(); + useResolvedExtensionsMock.mockReturnValue([[], false]); + }); + + it('should return unloaded when extensions are not resolved and localStorage is empty', () => { + const { result } = renderHook(() => useForcedPerspective()); + expect(result.current).toEqual({ loaded: false, perspectiveId: null }); + }); + + it('should return null when no force-perspective extensions exist', async () => { + useResolvedExtensionsMock.mockReturnValue([[], true]); + + const { result } = renderHook(() => useForcedPerspective()); + + await waitFor(() => { + expect(result.current).toEqual({ loaded: true, perspectiveId: null }); + }); + }); + + it('should return forced perspective when a hook evaluates to true', async () => { + useResolvedExtensionsMock.mockReturnValue([ + [ + { + type: 'console.force-perspective', + properties: { + perspectiveId: 'virtualization-perspective', + useForcePerspective: () => [true, false], + }, + }, + ], + true, + ]); + + const { result } = renderHook(() => useForcedPerspective()); + + await waitFor(() => { + expect(result.current).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + }); + expect(window.localStorage.getItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY)).toBe( + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + }); + + it('should return cached forced perspective while extensions are unresolved', () => { + window.localStorage.setItem( + FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + useResolvedExtensionsMock.mockReturnValue([[], false]); + + const { result } = renderHook(() => useForcedPerspective()); + + expect(result.current).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + }); + + it('should return cached forced perspective while extension hooks are evaluating', () => { + window.localStorage.setItem( + FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + useResolvedExtensionsMock.mockReturnValue([ + [ + { + type: 'console.force-perspective', + properties: { + perspectiveId: 'virtualization-perspective', + useForcePerspective: () => [false, true], + }, + }, + ], + true, + ]); + + const { result } = renderHook(() => useForcedPerspective()); + + expect(result.current).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + }); + + it('should keep cached forced perspective when extensions resolve empty before plugins load', () => { + window.localStorage.setItem( + FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + useResolvedExtensionsMock.mockReturnValue([[], true]); + + const { result } = renderHook(() => useForcedPerspective()); + + expect(result.current).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + expect(window.localStorage.getItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY)).toBe( + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + }); + + it('should clear localStorage when forcing is no longer active', async () => { + window.localStorage.setItem( + FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + useResolvedExtensionsMock.mockReturnValue([ + [ + { + type: 'console.force-perspective', + properties: { + perspectiveId: 'virtualization-perspective', + useForcePerspective: () => [false, false], + }, + }, + ], + true, + ]); + + const { result } = renderHook(() => useForcedPerspective()); + + await waitFor(() => { + expect(result.current).toEqual({ loaded: true, perspectiveId: null }); + }); + expect(window.localStorage.getItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY)).toBeNull(); + }); + + it('should use the first extension that forces a perspective', async () => { + useResolvedExtensionsMock.mockReturnValue([ + [ + { + type: 'console.force-perspective', + properties: { + perspectiveId: 'admin', + useForcePerspective: () => [false, false], + }, + }, + { + type: 'console.force-perspective', + properties: { + perspectiveId: 'virtualization-perspective', + useForcePerspective: () => [true, false], + }, + }, + ], + true, + ]); + + const { result } = renderHook(() => useForcedPerspective()); + + await waitFor(() => { + expect(result.current).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + }); + }); + + it('should resolve force-perspective extensions via isForcePerspective', () => { + renderHook(() => useForcedPerspective()); + expect(useResolvedExtensionsMock).toHaveBeenCalledWith(isForcePerspective); + }); +}); diff --git a/frontend/packages/console-shared/src/hooks/forcedPerspectiveContext.ts b/frontend/packages/console-shared/src/hooks/forcedPerspectiveContext.ts new file mode 100644 index 00000000000..8c784ce531b --- /dev/null +++ b/frontend/packages/console-shared/src/hooks/forcedPerspectiveContext.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; +import type { ForcedPerspectiveResult } from '../utils/forcedPerspective'; + +export const ForcedPerspectiveContext = createContext(null); + +/** Reads the forced perspective from DetectContext. Avoids duplicate useForcedPerspective calls. */ +export const useForcedPerspectiveContext = (): ForcedPerspectiveResult => { + const context = useContext(ForcedPerspectiveContext); + if (!context) { + throw new Error( + 'useForcedPerspectiveContext must be used within ForcedPerspectiveContext.Provider', + ); + } + return context; +}; diff --git a/frontend/packages/console-shared/src/hooks/useForcedPerspective.ts b/frontend/packages/console-shared/src/hooks/useForcedPerspective.ts new file mode 100644 index 00000000000..1f5cb57276c --- /dev/null +++ b/frontend/packages/console-shared/src/hooks/useForcedPerspective.ts @@ -0,0 +1,96 @@ +import { useEffect, useMemo, useState } from 'react'; +import { isForcePerspective, useResolvedExtensions } from '@console/dynamic-plugin-sdk'; +import type { ForcedPerspectiveResult } from '../utils/forcedPerspective'; +import { + clearForcedPerspectiveFromStorage, + getInitialForcedPerspectiveResult, + setForcedPerspectiveInStorage, +} from '../utils/forcedPerspective'; + +type ForcedPerspectiveEvaluation = ForcedPerspectiveResult & { + /** Whether hook evaluation produced a definitive result. */ + definitive: boolean; +}; + +/** + * Resolves console.force-perspective extensions and returns the active forced perspective. + * The initial result is read synchronously from localStorage so callers do not wait for + * plugins to resolve on reload. Storage is updated once hook evaluation is definitive. + */ +export const useForcedPerspective = (): ForcedPerspectiveResult => { + const [extensions, extensionsResolved] = useResolvedExtensions(isForcePerspective); + const [cachedResult] = useState(getInitialForcedPerspectiveResult); + + const evaluation = useMemo((): ForcedPerspectiveEvaluation => { + if (!extensionsResolved) { + return { loaded: false, perspectiveId: null, definitive: false }; + } + + if (extensions.length === 0) { + return { + loaded: true, + perspectiveId: null, + definitive: !cachedResult.perspectiveId, + }; + } + + let loading = false; + let perspectiveId: string | null = null; + + extensions.forEach((extension) => { + if (perspectiveId || loading) { + return; + } + const hook = extension.properties.useForcePerspective; + if (!hook) { + return; + } + const result = (hook as () => [boolean, boolean])(); + if (!result) { + return; + } + const [shouldForce, isLoading] = result; + if (isLoading) { + loading = true; + return; + } + if (shouldForce) { + perspectiveId = extension.properties.perspectiveId; + } + }); + + if (loading) { + return { loaded: false, perspectiveId: null, definitive: false }; + } + + return { loaded: true, perspectiveId, definitive: true }; + }, [extensions, extensionsResolved, cachedResult.perspectiveId]); + + const result = useMemo((): ForcedPerspectiveResult => { + if (evaluation.definitive) { + return { loaded: evaluation.loaded, perspectiveId: evaluation.perspectiveId }; + } + + if (cachedResult.loaded && cachedResult.perspectiveId) { + return cachedResult; + } + + return { loaded: evaluation.loaded, perspectiveId: evaluation.perspectiveId }; + }, [cachedResult, evaluation]); + + useEffect(() => { + if (!evaluation.definitive) { + return; + } + if (evaluation.perspectiveId) { + setForcedPerspectiveInStorage({ + perspectiveId: evaluation.perspectiveId, + forced: true, + }); + return; + } + clearForcedPerspectiveFromStorage(); + }, [evaluation]); + + return result; +}; diff --git a/frontend/packages/console-shared/src/hooks/usePerspectives.ts b/frontend/packages/console-shared/src/hooks/usePerspectives.ts index 3f92a3cec5d..c5b6968567b 100644 --- a/frontend/packages/console-shared/src/hooks/usePerspectives.ts +++ b/frontend/packages/console-shared/src/hooks/usePerspectives.ts @@ -8,6 +8,7 @@ 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 { getForcedPerspectiveFromStorage } from '../utils/forcedPerspective'; const PERSPECTIVE_VISITED_FEATURE_KEY = 'perspective.visited'; @@ -157,16 +158,28 @@ export const usePerspectives = (): LoadedExtension[] => { } }, [perspectiveExtensions, handleResults]); const perspectives = useMemo(() => { + let availablePerspectives: LoadedExtension[]; if (!window.SERVER_FLAGS.perspectives) { - return perspectiveExtensions; + availablePerspectives = perspectiveExtensions; + } else { + const filteredExtensions = perspectiveExtensions.filter((e) => results[e.properties.id]); + + availablePerspectives = + filteredExtensions.length === 0 && + Object.keys(results).length === perspectiveExtensions.length + ? perspectiveExtensions.filter((p) => p.properties.id === 'admin') + : filteredExtensions; } - const filteredExtensions = perspectiveExtensions.filter((e) => results[e.properties.id]); + const forcedPerspectiveId = getForcedPerspectiveFromStorage()?.perspectiveId; + if (forcedPerspectiveId) { + const forcedOnly = availablePerspectives.filter( + (p) => p.properties.id === forcedPerspectiveId, + ); + return forcedOnly.length > 0 ? forcedOnly : availablePerspectives; + } - return filteredExtensions.length === 0 && - Object.keys(results).length === perspectiveExtensions.length - ? perspectiveExtensions.filter((p) => p.properties.id === 'admin') - : filteredExtensions; + return availablePerspectives; }, [perspectiveExtensions, results]); return perspectives; }; diff --git a/frontend/packages/console-shared/src/utils/__tests__/forcedPerspective.spec.ts b/frontend/packages/console-shared/src/utils/__tests__/forcedPerspective.spec.ts new file mode 100644 index 00000000000..96f67f20529 --- /dev/null +++ b/frontend/packages/console-shared/src/utils/__tests__/forcedPerspective.spec.ts @@ -0,0 +1,61 @@ +import { + clearForcedPerspectiveFromStorage, + FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, + getForcedPerspectiveFromStorage, + getInitialForcedPerspectiveResult, + setForcedPerspectiveInStorage, +} from '../forcedPerspective'; + +describe('forcedPerspective utils', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('should persist and read forced perspective state', () => { + setForcedPerspectiveInStorage({ + perspectiveId: 'virtualization-perspective', + forced: true, + }); + + expect(getForcedPerspectiveFromStorage()).toEqual({ + perspectiveId: 'virtualization-perspective', + forced: true, + }); + expect(window.localStorage.getItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY)).toBe( + JSON.stringify({ + perspectiveId: 'virtualization-perspective', + forced: true, + }), + ); + }); + + it('should clear forced perspective state', () => { + setForcedPerspectiveInStorage({ + perspectiveId: 'virtualization-perspective', + forced: true, + }); + + clearForcedPerspectiveFromStorage(); + + expect(getForcedPerspectiveFromStorage()).toBeNull(); + }); + + it('should return initial forced perspective result from localStorage', () => { + setForcedPerspectiveInStorage({ + perspectiveId: 'virtualization-perspective', + forced: true, + }); + + expect(getInitialForcedPerspectiveResult()).toEqual({ + loaded: true, + perspectiveId: 'virtualization-perspective', + }); + }); + + it('should return unloaded initial result when localStorage is empty', () => { + expect(getInitialForcedPerspectiveResult()).toEqual({ + loaded: false, + perspectiveId: null, + }); + }); +}); diff --git a/frontend/packages/console-shared/src/utils/forcedPerspective.ts b/frontend/packages/console-shared/src/utils/forcedPerspective.ts new file mode 100644 index 00000000000..9d62735adc4 --- /dev/null +++ b/frontend/packages/console-shared/src/utils/forcedPerspective.ts @@ -0,0 +1,60 @@ +import { STORAGE_PREFIX } from '../constants/common'; + +export const FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY = `${STORAGE_PREFIX}/forced-perspective`; + +export type ForcedPerspectiveStorageState = { + perspectiveId: string; + forced: boolean; +}; + +export type ForcedPerspectiveResult = { + /** Whether all console.force-perspective hooks have finished evaluating. */ + loaded: boolean; + /** The forced perspective identifier, or null when no perspective is forced. */ + perspectiveId: string | null; +}; + +export const getForcedPerspectiveFromStorage = (): ForcedPerspectiveStorageState | null => { + try { + const value = window.localStorage.getItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY); + if (!value) { + return null; + } + const parsed = JSON.parse(value) as ForcedPerspectiveStorageState; + if (parsed?.forced && parsed?.perspectiveId) { + return parsed; + } + return null; + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not read forced perspective from localStorage', e); + return null; + } +}; + +/** Read the last known forced perspective synchronously from localStorage. */ +export const getInitialForcedPerspectiveResult = (): ForcedPerspectiveResult => { + const cached = getForcedPerspectiveFromStorage(); + if (cached?.forced && cached.perspectiveId) { + return { loaded: true, perspectiveId: cached.perspectiveId }; + } + return { loaded: false, perspectiveId: null }; +}; + +export const setForcedPerspectiveInStorage = (state: ForcedPerspectiveStorageState): void => { + try { + window.localStorage.setItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not persist forced perspective to localStorage', e); + } +}; + +export const clearForcedPerspectiveFromStorage = (): void => { + try { + window.localStorage.removeItem(FORCED_PERSPECTIVE_LOCAL_STORAGE_KEY); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not clear forced perspective from localStorage', e); + } +};