diff --git a/config/webpack.plugins.js b/config/webpack.plugins.js index 742efdb7d..b28d07268 100644 --- a/config/webpack.plugins.js +++ b/config/webpack.plugins.js @@ -47,6 +47,7 @@ const plugins = (dev = false, beta = false, restricted = false) => { { '@openshift/dynamic-plugin-sdk': { singleton: true, requiredVersion: deps['@openshift/dynamic-plugin-sdk'] } }, { '@patternfly/quickstarts': { singleton: true, requiredVersion: deps['@patternfly/quickstarts'] } }, { '@redhat-cloud-services/chrome': { singleton: true, requiredVersion: deps['@redhat-cloud-services/chrome'] } }, + { '@scalprum/core': { singleton: true, requiredVersion: deps['@scalprum/core'] } }, { '@scalprum/react-core': { singleton: true, requiredVersion: deps['@scalprum/react-core'] } }, { '@unleash/proxy-client-react': { singleton: true, requiredVersion: deps['@unleash/proxy-client-react'] } }, getDynamicModules(process.cwd()), diff --git a/cypress/component/ChromeRoutes/ChromeRoute.cy.tsx b/cypress/component/ChromeRoutes/ChromeRoute.cy.tsx new file mode 100644 index 000000000..7fe07d165 --- /dev/null +++ b/cypress/component/ChromeRoutes/ChromeRoute.cy.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import ChromeRoute from '../../../src/components/ChromeRoute'; +import { initializeVisibilityFunctions } from '../../../src/utils/VisibilitySingleton'; +import { ChromeUser } from '@redhat-cloud-services/types'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; +import { IntlProvider } from 'react-intl'; +import ScalprumProvider from '@scalprum/react-core'; +import { initialize, removeScalprum } from '@scalprum/core'; + +const defaultUser: ChromeUser = { + entitlements: {}, + identity: { + user: { + is_org_admin: true, + is_active: true, + is_internal: true, + email: 'foo@redhat.com', + first_name: 'Joe', + last_name: 'Doe', + locale: 'en', + username: 'jdoe', + }, + org_id: '1', + type: 'User', + account_number: '2', + internal: { + org_id: '1', + account_id: '3', + }, + }, +}; + +const defaultVisibilityOptions: Parameters[0] = { + getToken: () => Promise.resolve('token'), + getUser: () => Promise.resolve(defaultUser), + getUserPermissions: () => Promise.resolve([]), + isPreview: false, +}; + +const Wrapper = ({ children, getUser }: React.PropsWithChildren<{ getUser?: () => Promise }>) => { + const [isReady, setIsReady] = useState(false); + const visibilityOptions: Parameters[0] = { + getToken: defaultVisibilityOptions.getToken, + getUser: getUser ?? defaultVisibilityOptions.getUser, + getUserPermissions: defaultVisibilityOptions.getUserPermissions, + isPreview: defaultVisibilityOptions.isPreview, + }; + const scalprum = useRef( + initialize({ + appsConfig: { + foo: { + name: 'foo', + manifestLocation: '/bar/manifest.json', + }, + }, + }) + ); + useEffect(() => { + initializeVisibilityFunctions(visibilityOptions); + // mock the module + scalprum.current.exposedModules['foo#foo'] = { + default: () =>
FooBar
, + }; + + setIsReady(true); + return () => { + removeScalprum(); + }; + }, []); + + if (!isReady) { + return null; + } + + return ( + + + state)}> + + + + + + + + + ); +}; + +describe('ChromeRoute', () => { + it('should render not found route if permissions are not met', () => { + cy.mount( + + + + ); + + cy.contains('We lost that page').should('be.visible'); + }); + + it('should not render page if there is error while checking permissions', () => { + cy.mount( + Promise.reject('expected error')}> + + + ); + + cy.contains('We lost that page').should('be.visible'); + }); + + it('should render page if permissions are met', () => { + cy.mount( + + + + ); + + cy.contains('FooBar').should('be.visible'); + }); + + it('should render route if no permissions are provided', () => { + cy.mount( + + + + ); + cy.contains('FooBar').should('be.visible'); + }); +}); diff --git a/cypress/component/Preview/BetaSwitcher.cy.tsx b/cypress/component/Preview/BetaSwitcher.cy.tsx new file mode 100644 index 000000000..4c16894a4 --- /dev/null +++ b/cypress/component/Preview/BetaSwitcher.cy.tsx @@ -0,0 +1,130 @@ +import React, { PropsWithChildren } from 'react'; +import { Provider, createStore, useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import BetaSwitcher from '../../../src/components/BetaSwitcher'; +import { userConfigAtom } from '../../../src/state/atoms/userConfigAtom'; +import { ChromeUserConfig } from '../../../src/utils/initUserConfig'; +import { isPreviewAtom } from '../../../src/state/atoms/releaseAtom'; + +const HydrateAtoms = ({ initialValues, children }: PropsWithChildren<{ initialValues: any }>) => { + useHydrateAtoms(initialValues); + return children; +}; + +const TestProvider = ({ initialValues, children }: PropsWithChildren<{ initialValues: any }>) => ( + + {children} + +); + +const Wrapper = () => { + return ; +}; + +describe('BetaSwitcher', () => { + it('should show preview modal on first user preview toggle', () => { + const userConfig: ChromeUserConfig = { + data: { + uiPreview: false, + uiPreviewSeen: false, + }, + } as ChromeUserConfig; + cy.intercept('POST', 'api/chrome-service/v1/user/mark-preview-seen', { + data: { + uiPreview: true, + uiPreviewSeen: true, + }, + }); + cy.mount( + + + + ); + + cy.contains('turn on Preview mode').should('exist'); + + cy.get('#preview-toggle').click(); + cy.contains('Turn on').should('exist'); + cy.contains('Turn on').click(); + cy.contains('Welcome to preview').should('exist'); + cy.wait(5000); + // popover disappears after 5 seconds on its own + cy.contains('Welcome to preview').should('not.exist'); + cy.contains('turn off Preview mode').should('exist'); + + // turn off preview again + cy.get('#preview-toggle').click(); + cy.contains('turn on Preview mode').should('exist'); + }); + + it('should not show preview modal on subsequent user preview toggles', () => { + const userConfig: ChromeUserConfig = { + data: { + uiPreview: false, + uiPreviewSeen: true, + }, + } as ChromeUserConfig; + cy.mount( + + + + ); + + cy.contains('turn on Preview mode').should('exist'); + cy.contains('Turn on').should('not.exist'); + + cy.get('#preview-toggle').click(); + cy.contains('turn off Preview mode').should('exist'); + + // turn off preview again + cy.get('#preview-toggle').click(); + cy.contains('turn on Preview mode').should('exist'); + }); + + it('should hide the entire banner in stable environment, but show in preview', () => { + const FakePreviewToggle = () => { + const togglePreview = useSetAtom(isPreviewAtom); + return ; + }; + const userConfig: ChromeUserConfig = { + data: { + uiPreview: false, + uiPreviewSeen: true, + }, + } as ChromeUserConfig; + const store = createStore(); + store.set(isPreviewAtom, false); + cy.mount( + + + + + ); + + // if the node is turned off immediately it sometimes doesn't render the banner and the test fails + cy.wait(1000); + cy.get('.pf-v5-c-menu-toggle').click(); + cy.get('.pf-v5-c-menu__item-text').should('exist'); + cy.get('.pf-v5-c-menu__item-text').click(); + cy.get('.pf-v5-c-menu-toggle').should('not.exist'); + + // turn preview and banner should show + cy.contains('Fake').click(); + cy.contains('Welcome to preview').should('exist'); + }); +}); diff --git a/docs/disablingPf4.md b/docs/disablingPf4.md new file mode 100644 index 000000000..66b7b0892 --- /dev/null +++ b/docs/disablingPf4.md @@ -0,0 +1,11 @@ +# Disabling PF4 styling + +> Note: This flag is mean to be used for debugging purposes to help visually identify usage of outdated PF version + +## To remove PF4 styling support follow these steps + +1. Open your browser developer tool and access the "console" tab +2. Run this command: `window.insights.chrome.enable.disabledPf4()` +3. Refresh the browser page + +> Note: The flag uses localStorage for storage. The browser will remember the flag until the local storage is cleared. To remove the flag run `localStorage.clear()` command in you console and refresh the page. diff --git a/package-lock.json b/package-lock.json index 828730244..6db288a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,7 +134,7 @@ "url": "^0.11.3", "utility-types": "^3.11.0", "wait-on": "^7.2.0", - "webpack": "^5.92.1", + "webpack": "^5.94.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", @@ -8878,26 +8878,6 @@ "@types/ms": "*" } }, - "node_modules/@types/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "0.0.50", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz", @@ -14282,9 +14262,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -29356,12 +29336,11 @@ } }, "node_modules/webpack": { - "version": "5.92.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", - "integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -29370,7 +29349,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", diff --git a/package.json b/package.json index 0fb5603e3..0bc384d95 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "url": "^0.11.3", "utility-types": "^3.11.0", "wait-on": "^7.2.0", - "webpack": "^5.92.1", + "webpack": "^5.94.0", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index 86369e1af..795d6ca4f 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -150,6 +150,7 @@ export type ModuleRoute = { dynamic?: boolean; props?: Record; supportCaseData?: SupportCaseConfig; + permissions?: NavItemPermission[]; }; export type RemoteModule = { diff --git a/src/auth/OIDCConnector/OIDCProvider.tsx b/src/auth/OIDCConnector/OIDCProvider.tsx index b53800b3e..325208c93 100644 --- a/src/auth/OIDCConnector/OIDCProvider.tsx +++ b/src/auth/OIDCConnector/OIDCProvider.tsx @@ -17,7 +17,10 @@ const OIDCProvider: React.FC = ({ children }) => { | undefined >(undefined); async function setupSSO() { - const { data } = await loadFedModules(); + const { + // ignore $schema from the data as it is an spec ref + data: { $schema: ignore, ...data }, + } = await loadFedModules(); try { const { chrome: { diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index 0c201d627..93eced07a 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -18,6 +18,7 @@ import AppPlaceholder from './components/AppPlaceholder'; import { ChromeUserConfig, initChromeUserConfig } from './utils/initUserConfig'; import ChromeAuthContext from './auth/ChromeAuthContext'; import useSuspenseLoader from '@redhat-cloud-services/frontend-components-utilities/useSuspenseLoader/useSuspenseLoader'; +import { userConfigAtom } from './state/atoms/userConfigAtom'; const isITLessEnv = ITLess(); const language: keyof typeof messages = 'en'; @@ -55,8 +56,10 @@ const App = ({ initApp }: { initApp: (...args: Parameters { const initPreview = useSetAtom(isPreviewAtom); + const setUserConfig = useSetAtom(userConfigAtom); function initSuccess(userConfig: ChromeUserConfig) { initPreview(userConfig.data.uiPreview); + setUserConfig(userConfig); } function initFail() { initPreview(false); diff --git a/src/components/AllServicesDropdown/PlatformServicesLinks.tsx b/src/components/AllServicesDropdown/PlatformServicesLinks.tsx index 53d6c6a1c..d8d96b7a8 100644 --- a/src/components/AllServicesDropdown/PlatformServicesLinks.tsx +++ b/src/components/AllServicesDropdown/PlatformServicesLinks.tsx @@ -5,7 +5,7 @@ const PlatformServiceslinks = () => { return ( <> - Red Hat Ansible Platform + Red Hat Ansible Automation Platform Red Hat Enterprise Linux diff --git a/src/components/BetaSwitcher/BetaInfoModal.tsx b/src/components/BetaSwitcher/BetaInfoModal.tsx new file mode 100644 index 000000000..dba2fe422 --- /dev/null +++ b/src/components/BetaSwitcher/BetaInfoModal.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Modal, ModalVariant } from '@patternfly/react-core/dist/dynamic/components/Modal'; +import WrenchIcon from '@patternfly/react-icons/dist/dynamic/icons/wrench-icon'; +import { Label } from '@patternfly/react-core/dist/dynamic/components/Label'; +import { Text, TextContent } from '@patternfly/react-core/dist/dynamic/components/Text'; +import { Title } from '@patternfly/react-core/dist/dynamic/components/Title'; +import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; + +const BetaInfoModal = ({ onAccept, isOpen, toggleOpen }: { toggleOpen: (isOpen: boolean) => void; isOpen: boolean; onAccept: () => void }) => { + const Header = () => ( + + Preview{' '} + <Label> + <WrenchIcon /> + </Label> + + ); + return ( + toggleOpen(false)} + variant={ModalVariant.small} + header={
} + actions={[ + , + , + ]} + > + + + You can enable Preview mode to try out upcoming features that are in technology preview.{' '} + + Learn more + + . + + + + ); +}; + +export default BetaInfoModal; diff --git a/src/components/BetaSwitcher/BetaSwitcher.scss b/src/components/BetaSwitcher/BetaSwitcher.scss new file mode 100644 index 000000000..5f77b34d9 --- /dev/null +++ b/src/components/BetaSwitcher/BetaSwitcher.scss @@ -0,0 +1,7 @@ +.chr-c-beta-switcher { + background-color: var(--pf-v5-global--BackgroundColor--dark-400); + transition: background-color 0.3s; + &.active { + background-color: var(--pf-v5-global--palette--blue-200); + } +} diff --git a/src/components/BetaSwitcher/BetaSwitcher.tsx b/src/components/BetaSwitcher/BetaSwitcher.tsx new file mode 100644 index 000000000..774acfa1f --- /dev/null +++ b/src/components/BetaSwitcher/BetaSwitcher.tsx @@ -0,0 +1,139 @@ +import React, { PropsWithChildren, useEffect, useRef } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import classNames from 'classnames'; +import { Bullseye } from '@patternfly/react-core/dist/dynamic/layouts/Bullseye'; +import { Switch } from '@patternfly/react-core/dist/dynamic/components/Switch'; +import { Text, TextVariants } from '@patternfly/react-core/dist/dynamic/components/Text'; +import WrenchIcon from '@patternfly/react-icons/dist/dynamic/icons/wrench-icon'; +import { Popover } from '@patternfly/react-core/dist/dynamic/components/Popover'; +import { Label } from '@patternfly/react-core/dist/dynamic/components/Label'; +import { Split, SplitItem } from '@patternfly/react-core/dist/dynamic/layouts/Split'; +import { + hidePreviewBannerAtom, + isPreviewAtom, + previewModalOpenAtom, + setPreviewSeenAtom, + togglePreviewWithCheckAtom, +} from '../../state/atoms/releaseAtom'; +import BetaInfoModal from './BetaInfoModal'; +import { userConfigAtom } from '../../state/atoms/userConfigAtom'; +import BetaSwitcherDropdown from './BetaSwitcherDropdown'; + +import './BetaSwitcher.scss'; + +const BetaPopover = ({ children, isFirstTime }: PropsWithChildren<{ isFirstTime: boolean }>) => { + const [isVisible, setIsVisible] = React.useState(isFirstTime); + useEffect(() => { + let timeout: NodeJS.Timeout; + if (isFirstTime) { + setIsVisible(true); + timeout = setTimeout(() => { + setIsVisible(false); + }, 5000); + } + return () => { + if (timeout) { + clearTimeout(timeout); + setIsVisible(false); + } + }; + }, [isFirstTime]); + return ( + setIsVisible(false)} + headerContent="Welcome to preview" + bodyContent={ + + Look for items with this icon  + +  to quickly identify preview features. + + } + > + <>{children} + + ); +}; + +const BetaSwitcher = () => { + const bannerRef = useRef(null); + const [hideBanner, setHideBanner] = useAtom(hidePreviewBannerAtom); + const [isPreview, setIsPreview] = useAtom(isPreviewAtom); + const togglePreviewWithCheck = useSetAtom(togglePreviewWithCheckAtom); + const setUserPreviewSeen = useSetAtom(setPreviewSeenAtom); + const [isBetaModalOpen, setIsBetaModalOpen] = useAtom(previewModalOpenAtom); + const { + data: { uiPreviewSeen }, + } = useAtomValue(userConfigAtom); + useEffect(() => { + const chromeRenderElement = document.getElementById('chrome-app-render-root'); + // adjust the height of the chrome render element to fit the banner and not show extra scrollbar + if (!hideBanner && bannerRef.current && chromeRenderElement) { + const { height } = bannerRef.current.getBoundingClientRect(); + chromeRenderElement.style.height = `calc(100vh - ${height}px)`; + } else if (hideBanner && chromeRenderElement) { + chromeRenderElement.style.removeProperty('height'); + } + if (isPreview) { + // preview should always reset the banner visibility + setHideBanner(false); + } + }, [isPreview, hideBanner]); + + const handleBetaAccept = () => { + setIsBetaModalOpen(false); + setIsPreview(true); + setUserPreviewSeen(); + }; + + if (hideBanner) { + return null; + } + + return ( +
+ + + + + + You're in Hybrid Cloud Console Preview mode. To return to production, turn off Preview mode + + } + labelOff={ + + You're in Hybrid Cloud Console production. To see new pre-production features, turn on Preview mode + + } + aria-label="preview-toggle" + isChecked={isPreview} + onChange={(_e, checked) => togglePreviewWithCheck(checked)} + isReversed + /> + + + + {!isPreview ? ( + + + + ) : null} + + {!uiPreviewSeen ? : null} +
+ ); +}; + +export default BetaSwitcher; diff --git a/src/components/BetaSwitcher/BetaSwitcherDropdown.tsx b/src/components/BetaSwitcher/BetaSwitcherDropdown.tsx new file mode 100644 index 000000000..1dbddb291 --- /dev/null +++ b/src/components/BetaSwitcher/BetaSwitcherDropdown.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; +import { MenuToggle, MenuToggleElement } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; +import { EllipsisVIcon } from '@patternfly/react-icons/dist/dynamic/icons/ellipsis-v-icon'; +import { Text } from '@patternfly/react-core/dist/dynamic/components/Text'; +import { CogIcon } from '@patternfly/react-icons/dist/dynamic/icons/cog-icon'; +import { useSetAtom } from 'jotai'; +import { hidePreviewBannerAtom } from '../../state/atoms/releaseAtom'; + +const BetaSwitcherDropdown = () => { + const hidePreview = useSetAtom(hidePreviewBannerAtom); + const [isOpen, setIsOpen] = useState(false); + + const onToggleClick = () => { + setIsOpen((prev) => !prev); + }; + + const description = ( + + You can enable "Preview" from the Settings menu at any time. + + ); + + return ( + setIsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + + + + )} + shouldFocusToggleOnSelect + > + + { + hidePreview(true); + setIsOpen(false); + }} + description={description} + key="hide-preview-banner" + > + Hide "Preview" banner + + + + ); +}; + +export default BetaSwitcherDropdown; diff --git a/src/components/BetaSwitcher/index.ts b/src/components/BetaSwitcher/index.ts new file mode 100644 index 000000000..1497d220b --- /dev/null +++ b/src/components/BetaSwitcher/index.ts @@ -0,0 +1 @@ +export { default } from './BetaSwitcher'; diff --git a/src/components/ChromeRoute/ChromeRoute.tsx b/src/components/ChromeRoute/ChromeRoute.tsx index f8e7cf7b4..b0c416f71 100644 --- a/src/components/ChromeRoute/ChromeRoute.tsx +++ b/src/components/ChromeRoute/ChromeRoute.tsx @@ -1,5 +1,5 @@ import { ScalprumComponent } from '@scalprum/react-core'; -import React, { memo, useContext, useEffect } from 'react'; +import React, { memo, useContext, useEffect, useState } from 'react'; import LoadingFallback from '../../utils/loading-fallback'; import { batch, useDispatch } from 'react-redux'; import { toggleGlobalFilter } from '../../redux/actions'; @@ -15,6 +15,9 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; import { gatewayErrorAtom } from '../../state/atoms/gatewayErrorAtom'; import { isPreviewAtom } from '../../state/atoms/releaseAtom'; +import { NavItemPermission } from '../../@types/types'; +import { evaluateVisibility } from '../../utils/isNavItemVisible'; +import NotFoundRoute from '../NotFoundRoute'; export type ChromeRouteProps = { scope: string; @@ -23,19 +26,39 @@ export type ChromeRouteProps = { exact?: boolean; scopeClass?: string; props?: any; + permissions?: NavItemPermission[]; }; // eslint-disable-next-line react/display-name const ChromeRoute = memo( - ({ scope, module, scopeClass, path, props }: ChromeRouteProps) => { + ({ scope, module, scopeClass, path, props, permissions }: ChromeRouteProps) => { const isPreview = useAtomValue(isPreviewAtom); const dispatch = useDispatch(); const { setActiveHelpTopicByName } = useContext(HelpTopicContext); const { user } = useContext(ChromeAuthContext); const gatewayError = useAtomValue(gatewayErrorAtom); + const [isHidden, setIsHidden] = useState(null); const setActiveModule = useSetAtom(activeModuleAtom); + async function checkPermissions(permissions: NavItemPermission[]) { + try { + const withResult = await Promise.all(permissions.map((permission) => evaluateVisibility({ permissions: permission }))); + setIsHidden(withResult.some((result) => result.isHidden)); + } catch (error) { + console.error('Error while checking route permissions', error); + // if there is an error, hide the route + // better missing page than runtime error that brings down entire chrome + setIsHidden(true); + } + } + + useEffect(() => { + if (Array.isArray(permissions)) { + checkPermissions(permissions); + } + }, [permissions]); + useEffect(() => { batch(() => { // Only trigger update on a first application render before any active module has been selected @@ -60,6 +83,8 @@ const ChromeRoute = memo( */ setActiveHelpTopicByName && setActiveHelpTopicByName(''); + // reset visibility function + setIsHidden(null); return () => { /** * Reset global filter when switching application @@ -71,6 +96,16 @@ const ChromeRoute = memo( if (gatewayError) { return ; } + + if (isHidden === null && Array.isArray(permissions)) { + return LoadingFallback; + } + + if (isHidden) { + // do not spill the beans about hidden routes + return ; + } + return (
- + + + + +
`; diff --git a/src/components/Header/PreviewAlert.tsx b/src/components/Header/PreviewAlert.tsx deleted file mode 100644 index 58caab9b3..000000000 --- a/src/components/Header/PreviewAlert.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React, { useState } from 'react'; -import cookie from 'js-cookie'; -import HeaderAlert from './HeaderAlert'; -import { useAtom } from 'jotai'; -import { isPreviewAtom } from '../../state/atoms/releaseAtom'; -import { AlertActionLink, AlertVariant } from '@patternfly/react-core/dist/dynamic/components/Alert'; - -const PreviewAlert = () => { - const [isPreview, togglePreview] = useAtom(isPreviewAtom); - const [prevPreviewValue, setPrevPreviewValue] = useState(isPreview); - const shouldRenderAlert = isPreview !== prevPreviewValue; - - function handlePreviewToggle() { - togglePreview(); - } - - return shouldRenderAlert ? ( - - - Learn more - - { - handlePreviewToggle(); - }} - >{`${isPreview ? 'Disable' : 'Enable'} preview`} - - } - onDismiss={() => { - cookie.set('cs_toggledRelease', 'false'); - setPrevPreviewValue(isPreview); - }} - /> - ) : null; -}; - -export default PreviewAlert; diff --git a/src/components/Header/SettingsToggle.tsx b/src/components/Header/SettingsToggle.tsx index 337559361..2d3f6365c 100644 --- a/src/components/Header/SettingsToggle.tsx +++ b/src/components/Header/SettingsToggle.tsx @@ -9,7 +9,7 @@ import ChromeLink from '../ChromeLink/ChromeLink'; import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type SettingsToggleDropdownGroup = { - title: string; + title?: string; items: SettingsToggleDropdownItem[]; }; @@ -20,6 +20,7 @@ export type SettingsToggleDropdownItem = { isHidden?: boolean; isDisabled?: boolean; rel?: string; + ouiaId?: string; }; export type SettingsToggleProps = { @@ -43,14 +44,19 @@ const SettingsToggle = (props: SettingsToggleProps) => { {items.map(({ url, title, onClick, isHidden, isDisabled, rel = 'noopener noreferrer', ...rest }) => !isHidden ? ( ( - - {title} - - )} + component={ + onClick + ? undefined + : ({ className: itemClassName }) => ( + + {title} + + ) + } > {title} diff --git a/src/components/Header/Tools.tsx b/src/components/Header/Tools.tsx index f5c4d4f9d..83a198866 100644 --- a/src/components/Header/Tools.tsx +++ b/src/components/Header/Tools.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { memo, useContext, useEffect, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; import { Divider } from '@patternfly/react-core/dist/dynamic/components/Divider'; import { DropdownItem } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; @@ -12,7 +12,7 @@ import QuestionCircleIcon from '@patternfly/react-icons/dist/dynamic/icons/quest import CogIcon from '@patternfly/react-icons/dist/dynamic/icons/cog-icon'; import RedhatIcon from '@patternfly/react-icons/dist/dynamic/icons/redhat-icon'; import UserToggle from './UserToggle'; -import ToolbarToggle, { ToolbarToggleDropdownItem } from './ToolbarToggle'; +import ToolbarToggle from './ToolbarToggle'; import SettingsToggle, { SettingsToggleDropdownGroup } from './SettingsToggle'; import cookie from 'js-cookie'; import { ITLess, getSection } from '../../utils/common'; @@ -21,11 +21,9 @@ import { useFlag } from '@unleash/proxy-client-react'; import messages from '../../locales/Messages'; import { createSupportCase } from '../../utils/createCase'; import BellIcon from '@patternfly/react-icons/dist/dynamic/icons/bell-icon'; -import useWindowWidth from '../../hooks/useWindowWidth'; import ChromeAuthContext from '../../auth/ChromeAuthContext'; -import { isPreviewAtom } from '../../state/atoms/releaseAtom'; +import { isPreviewAtom, togglePreviewWithCheckAtom } from '../../state/atoms/releaseAtom'; import { notificationDrawerExpandedAtom, unreadNotificationsAtom } from '../../state/atoms/notificationDrawerAtom'; -import PreviewAlert from './PreviewAlert'; import useSupportCaseData from '../../hooks/useSupportCaseData'; const isITLessEnv = ITLess(); @@ -43,26 +41,6 @@ const InternalButton = () => ( ); -type SettingsButtonProps = { - settingsMenuDropdownItems: ToolbarToggleDropdownItem[]; -}; - -const SettingsButton = ({ settingsMenuDropdownItems }: SettingsButtonProps) => ( - - } - id="SettingsMenu" - ariaLabel="Settings menu" - ouiaId="chrome-settings" - hasToggleIndicator={null} - widget-type="SettingsMenu" - dropdownItems={settingsMenuDropdownItems} - className="tooltip-button-settings-cy" - /> - -); - type ExpandedSettingsButtonProps = { settingsMenuDropdownGroups: SettingsToggleDropdownGroup[]; }; @@ -89,10 +67,10 @@ const Tools = () => { isRhosakEntitled: false, isDemoAcc: false, }); - const [isPreview, setIsPreview] = useAtom(isPreviewAtom); + const isPreview = useAtomValue(isPreviewAtom); + const togglePreviewWithCheck = useSetAtom(togglePreviewWithCheckAtom); const enableIntegrations = useFlag('platform.sources.integrations'); const enableGlobalLearningResourcesPage = useFlag('platform.learning-resources.global-learning-resources'); - const { xs } = useWindowWidth(); const { user, token } = useContext(ChromeAuthContext); const unreadNotifications = useAtomValue(unreadNotificationsAtom); const [isNotificationDrawerExpanded, toggleNotifications] = useAtom(notificationDrawerExpandedAtom); @@ -103,8 +81,6 @@ const Tools = () => { messages.betaRelease )}`; - const enableAuthDropdownOption = useFlag('platform.chrome.dropdown.authfactor'); - const enableExpandedSettings = useFlag('platform.chrome.expanded-settings'); const isNotificationsEnabled = useFlag('platform.chrome.notifications-drawer'); const enableMyUserAccessLanding = useFlag('platform.chrome.my-user-access-landing-page'); @@ -112,6 +88,16 @@ const Tools = () => { /* list out the items for the settings menu */ const settingsMenuDropdownGroups = [ + { + items: [ + { + ouiaId: 'PreviewSwitcher', + title: `${isPreview ? 'Exit' : 'Enable'} "Preview" mode`, + url: '#', + onClick: () => togglePreviewWithCheck(), + }, + ], + }, { title: 'Settings', items: [ @@ -148,24 +134,6 @@ const Tools = () => { }, ]; - // Old settings menu - const settingsMenuDropdownItems = [ - { - url: settingsPath, - title: 'Settings', - appId: 'sources', - }, - ...(enableAuthDropdownOption - ? [ - { - url: identityAndAccessManagmentPath, - title: 'Identity & Access Management', - appId: 'iam', - }, - ] - : []), - ]; - useEffect(() => { if (user) { setState({ @@ -233,7 +201,7 @@ const Tools = () => { }, { title: betaSwitcherTitle, - onClick: () => setIsPreview(), + onClick: () => togglePreviewWithCheck(), }, { title: 'separator' }, ...aboutMenuDropdownItems, @@ -255,21 +223,6 @@ const Tools = () => { ); - const BetaSwitcher = () => { - return ( - setIsPreview()} - isReversed - className="chr-c-beta-switcher" - /> - ); - }; - const ThemeToggle = () => { const [darkmode, setDarkmode] = useState(false); return ( @@ -289,16 +242,6 @@ const Tools = () => { return ( <> - - {!xs && } - {isNotificationsEnabled && ( @@ -327,11 +270,7 @@ const Tools = () => { )} - {enableExpandedSettings ? ( - - ) : ( - - )} + @@ -371,7 +310,6 @@ const Tools = () => { /> - ); }; diff --git a/src/components/RootApp/ScalprumRoot.test.js b/src/components/RootApp/ScalprumRoot.test.js index 378cf7151..098bf2754 100644 --- a/src/components/RootApp/ScalprumRoot.test.js +++ b/src/components/RootApp/ScalprumRoot.test.js @@ -42,14 +42,6 @@ jest.mock('@unleash/proxy-client-react', () => { }; }); -jest.mock('../../state/atoms/releaseAtom', () => { - const util = jest.requireActual('../../state/atoms/utils'); - return { - __esModule: true, - isPreviewAtom: util.atomWithToggle(false), - }; -}); - window.ResizeObserver = window.ResizeObserver || jest.fn().mockImplementation(() => ({ @@ -63,6 +55,8 @@ import { initializeVisibilityFunctions } from '../../utils/VisibilitySingleton'; import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { useHydrateAtoms } from 'jotai/utils'; import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; +import { hidePreviewBannerAtom, isPreviewAtom } from '../../state/atoms/releaseAtom'; +import { userConfigAtom } from '../../state/atoms/userConfigAtom'; const HydrateAtoms = ({ initialValues, children }) => { useHydrateAtoms(initialValues); @@ -207,13 +201,21 @@ describe('ScalprumRoot', () => { let getByLabelText; await act(async () => { const { getByLabelText: internalGetByLabelText } = await render( - - - - - - - + + + + + + + + + ); getByLabelText = internalGetByLabelText; }); diff --git a/src/components/RootApp/ScalprumRoot.tsx b/src/components/RootApp/ScalprumRoot.tsx index e21ee5643..75fd23460 100644 --- a/src/components/RootApp/ScalprumRoot.tsx +++ b/src/components/RootApp/ScalprumRoot.tsx @@ -37,6 +37,7 @@ import useTabName from '../../hooks/useTabName'; import { NotificationData, notificationDrawerDataAtom } from '../../state/atoms/notificationDrawerAtom'; import { isPreviewAtom } from '../../state/atoms/releaseAtom'; import { addNavListenerAtom, deleteNavListenerAtom } from '../../state/atoms/activeAppAtom'; +import BetaSwitcher from '../BetaSwitcher'; const ProductSelection = lazy(() => import('../Stratosphere/ProductSelection')); @@ -225,6 +226,7 @@ const ScalprumRoot = memo( + - +