diff --git a/cypress/component/AllServicesPage/AllServicesPage.cy.tsx b/cypress/component/AllServicesPage/AllServicesPage.cy.tsx index 3fc8141e6..94e6afcfc 100644 --- a/cypress/component/AllServicesPage/AllServicesPage.cy.tsx +++ b/cypress/component/AllServicesPage/AllServicesPage.cy.tsx @@ -51,9 +51,13 @@ describe('', () => { }, })); cy.mount( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - + ', () => { const elem = cy .mount( - + ) .get('html'); @@ -76,7 +76,7 @@ describe('', () => { const elem = cy .mount( - + ) .get('html'); diff --git a/cypress/component/OIDCConnector/OIDCSecured.cy.tsx b/cypress/component/OIDCConnector/OIDCSecured.cy.tsx index 4126f76c4..7521439f6 100644 --- a/cypress/component/OIDCConnector/OIDCSecured.cy.tsx +++ b/cypress/component/OIDCConnector/OIDCSecured.cy.tsx @@ -78,7 +78,6 @@ describe('ODIC Secured', () => { const authContextValue: AuthContextProps = { clearStaleState: () => Promise.resolve(), settings: authContextSettings, - events: {} as AuthContextProps['events'], removeUser: () => Promise.resolve(), signinRedirect: () => Promise.resolve(), isAuthenticated: true, @@ -94,6 +93,10 @@ describe('ODIC Secured', () => { startSilentRenew: () => Promise.resolve(), stopSilentRenew: () => Promise.resolve(), user: testUser, + events: { + addSilentRenewError: () => {}, + removeSilentRenewError: () => {}, + } as unknown as AuthContextProps['events'], }; beforeEach(() => { store = createStore((state = { chrome: {} }) => { @@ -105,7 +108,7 @@ describe('ODIC Secured', () => { cy.mount( - undefined}> + @@ -119,7 +122,7 @@ describe('ODIC Secured', () => { cy.mount( - undefined}> + @@ -133,7 +136,7 @@ describe('ODIC Secured', () => { cy.mount( - undefined}> + diff --git a/package-lock.json b/package-lock.json index 471a56171..bc152e01f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "@rhds/elements": "^2.0.1", "@rhds/icons": "^1.1.1", "@scalprum/core": "^0.8.1", - "@scalprum/react-core": "^0.9.1", + "@scalprum/react-core": "^0.9.3", "@segment/analytics-next": "^1.70.0", "@sentry/react": "^7.118.0", "@sentry/tracing": "^7.118.0", @@ -5024,13 +5024,13 @@ } }, "node_modules/@scalprum/react-core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.9.1.tgz", - "integrity": "sha512-r3ydkxwqtauWUH+NS1pWg9esWhLdOoH9XDomUhA3wLRSMs9R2HStWkKJ4NrryyMGafv+0I1djLkgfmaZk64z0g==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.9.3.tgz", + "integrity": "sha512-AnBoFLZl+qYB9XnAJBHYZJTHHRFWCW6iYAojGkiqtD4luyiEMmd6SaCv2s8xs/b6l/KKkG2FU272sIYqb/FkAQ==", "license": "Apache-2.0", "dependencies": { "@openshift/dynamic-plugin-sdk": "^5.0.1", - "@scalprum/core": "^0.7.0", + "@scalprum/core": "^0.8.1", "lodash": "^4.17.0" }, "peerDependencies": { @@ -5038,16 +5038,6 @@ "react-dom": ">=16.8.0 || >=17.0.0 || ^18.0.0" } }, - "node_modules/@scalprum/react-core/node_modules/@scalprum/core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@scalprum/core/-/core-0.7.0.tgz", - "integrity": "sha512-zvrPXexI+bxHGFY/teuwPI5yjnOuiq8uT+RDsrm3gnpr1AqZQVUiGdskl1ON/ci5lSs1kNadmXceF1BTKlicwg==", - "license": "Apache-2.0", - "dependencies": { - "@openshift/dynamic-plugin-sdk": "^5.0.1", - "tslib": "^2.6.2" - } - }, "node_modules/@segment/analytics-core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@segment/analytics-core/-/analytics-core-1.6.0.tgz", diff --git a/package.json b/package.json index a13e0e273..8ad100a63 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "@rhds/elements": "^2.0.1", "@rhds/icons": "^1.1.1", "@scalprum/core": "^0.8.1", - "@scalprum/react-core": "^0.9.1", + "@scalprum/react-core": "^0.9.3", "@segment/analytics-next": "^1.70.0", "@sentry/react": "^7.118.0", "@sentry/tracing": "^7.118.0", diff --git a/src/auth/ChromeAuthContext.ts b/src/auth/ChromeAuthContext.ts index 14c8b150a..6e22a2fb9 100644 --- a/src/auth/ChromeAuthContext.ts +++ b/src/auth/ChromeAuthContext.ts @@ -3,6 +3,8 @@ import { AxiosResponse } from 'axios'; import { createContext } from 'react'; import { OfflineTokenResponse } from './offline'; +export type ChromeLogin = (requiredScopes?: string[]) => Promise; + export type ChromeAuthContextValue = { ssoUrl: string; ready: boolean; @@ -12,7 +14,7 @@ export type ChromeAuthContextValue = { logoutAllTabs: (bounce?: boolean) => void; loginAllTabs: () => void; logout: () => void; - login: (requiredScopes?: string[]) => Promise; + login: ChromeLogin; tokenExpires: number; getToken: () => Promise; getRefreshToken: () => Promise; diff --git a/src/auth/OIDCConnector/OIDCSecured.tsx b/src/auth/OIDCConnector/OIDCSecured.tsx index 1d6a9c71d..8d135451d 100644 --- a/src/auth/OIDCConnector/OIDCSecured.tsx +++ b/src/auth/OIDCConnector/OIDCSecured.tsx @@ -184,7 +184,18 @@ export function OIDCSecured({ if (!auth.error) { startChrome(); } - }, [auth]); + function onRenewError(error: Error) { + console.error('Silent renew error', error); + state.login(); + } + auth.events.addSilentRenewError(onRenewError); + + return () => { + auth.events.removeSilentRenewError(onRenewError); + }; + // to ensure we are not re-initializing the chrome on every auth change + // only on the important events + }, [auth.error, auth.isLoading, auth.isAuthenticated, state.token, state.user?.identity?.account_number]); useEffect(() => { authRef.current = auth; diff --git a/src/components/Activation/Activation.tsx b/src/components/Activation/Activation.tsx index 009064b61..f71be47fb 100644 --- a/src/components/Activation/Activation.tsx +++ b/src/components/Activation/Activation.tsx @@ -1,6 +1,4 @@ import React, { useContext, useEffect, useState } from 'react'; -import { ChromeUser } from '@redhat-cloud-services/types'; -import { DeepRequired } from 'utility-types'; import { useNavigate } from 'react-router-dom'; import { Modal, ModalVariant } from '@patternfly/react-core/dist/dynamic/components/Modal'; import { Text, TextContent } from '@patternfly/react-core/dist/dynamic/components/Text'; @@ -9,7 +7,17 @@ import { useIntl } from 'react-intl'; import messages from '../../locales/Messages'; import InternalChromeContext from '../../utils/internalChromeContext'; -const Activation = ({ user, request }: { user: DeepRequired; request: string }) => { +const Activation = ({ + user, + request, +}: { + user: { + username: string; + accountNumber: string; + email: string; + }; + request: string; +}) => { const intl = useIntl(); const [isModalOpen, setIsModalOpen] = useState(true); const navigate = useNavigate(); @@ -29,7 +37,7 @@ const Activation = ({ user, request }: { user: DeepRequired; request 'Content-Type': 'application/json', }, body: JSON.stringify({ - description: `Username: ${user.identity.user.username}, Account ID: ${user.identity.account_number}, Email: ${user.identity.user.email}`, //eslint-disable-line + description: `Username: ${user.username}, Account ID: ${user.accountNumber}, Email: ${user.email}`, //eslint-disable-line summary: `Activation Request - for cloud-marketplace-enablement team`, labels: [request], }), diff --git a/src/components/ChromeRoute/ChromeRoute.tsx b/src/components/ChromeRoute/ChromeRoute.tsx index b0c416f71..2396ab78f 100644 --- a/src/components/ChromeRoute/ChromeRoute.tsx +++ b/src/components/ChromeRoute/ChromeRoute.tsx @@ -4,13 +4,9 @@ import LoadingFallback from '../../utils/loading-fallback'; import { batch, useDispatch } from 'react-redux'; import { toggleGlobalFilter } from '../../redux/actions'; import ErrorComponent from '../ErrorComponents/DefaultErrorComponent'; -import { getPendoConf } from '../../analytics'; import classNames from 'classnames'; import { HelpTopicContext } from '@patternfly/quickstarts'; import GatewayErrorComponent from '../ErrorComponents/GatewayErrorComponent'; -import { DeepRequired } from 'utility-types'; -import { ChromeUser } from '@redhat-cloud-services/types'; -import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { useAtomValue, useSetAtom } from 'jotai'; import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; import { gatewayErrorAtom } from '../../state/atoms/gatewayErrorAtom'; @@ -35,7 +31,6 @@ const ChromeRoute = memo( const isPreview = useAtomValue(isPreviewAtom); const dispatch = useDispatch(); const { setActiveHelpTopicByName } = useContext(HelpTopicContext); - const { user } = useContext(ChromeAuthContext); const gatewayError = useAtomValue(gatewayErrorAtom); const [isHidden, setIsHidden] = useState(null); @@ -65,18 +60,6 @@ const ChromeRoute = memo( // should be triggered only once per session setActiveModule(scope); }); - /** - * update pendo metadata on application change - */ - if (window.pendo) { - try { - window.pendo.updateOptions(getPendoConf(user as DeepRequired, isPreview)); - } catch (error) { - console.error('Unable to update pendo options'); - console.error(error); - } - } - /** * TODO: Discuss default close feature of topics * Topics drawer has no close button, therefore there might be an issue with opened topics after user changes route and does not clear the active topic trough the now non existing elements. diff --git a/src/components/ContextSwitcher/index.tsx b/src/components/ContextSwitcher/index.tsx index 40fc4cfc9..f936988ca 100644 --- a/src/components/ContextSwitcher/index.tsx +++ b/src/components/ContextSwitcher/index.tsx @@ -24,13 +24,13 @@ import { REQUESTS_COUNT, REQUESTS_DATA, } from '../../utils/consts'; -import { ChromeUser } from '@redhat-cloud-services/types'; import { useAtom } from 'jotai'; import { contextSwitcherOpenAtom } from '../../state/atoms/contextSwitcher'; export type ContextSwitcherProps = { - user: ChromeUser; className?: string; + accountNumber?: string; + isInternal?: boolean; }; // These attributes are present in the response based on the open API spec. @@ -41,12 +41,12 @@ type CrossAccountRequestInternal = CrossAccountRequest & { email: string; }; -const ContextSwitcher = ({ user, className }: ContextSwitcherProps) => { +const ContextSwitcher = ({ accountNumber, className, isInternal }: ContextSwitcherProps) => { const intl = useIntl(); const [isOpen, setIsOpen] = useAtom(contextSwitcherOpenAtom); const [data, setData] = useState([]); const [searchValue, setSearchValue] = useState(''); - const [selectedAccountNumber, setSelectedAccountNumber] = useState(user.identity.account_number); + const [selectedAccountNumber, setSelectedAccountNumber] = useState(accountNumber); const onSelect = () => { setIsOpen((prev) => !prev); }; @@ -80,10 +80,10 @@ const ContextSwitcher = ({ user, className }: ContextSwitcherProps) => { }; const resetAccountRequest = () => { - if (user?.identity?.account_number === selectedAccountNumber) { + if (accountNumber === selectedAccountNumber) { return; } - setSelectedAccountNumber(user?.identity?.account_number); + setSelectedAccountNumber(accountNumber); Cookies.remove(CROSS_ACCESS_ACCOUNT_NUMBER); Cookies.remove(CROSS_ACCESS_ORG_ID); localStorage.removeItem(ACTIVE_REMOTE_REQUEST); @@ -93,7 +93,7 @@ const ContextSwitcher = ({ user, className }: ContextSwitcherProps) => { useEffect(() => { let mounted = true; // only inernal users have the TAM features enabled - if (user?.identity?.user?.is_internal) { + if (isInternal) { const initialAccount = localStorage.getItem(ACTIVE_REMOTE_REQUEST); if (initialAccount) { try { @@ -121,7 +121,7 @@ const ContextSwitcher = ({ user, className }: ContextSwitcherProps) => { } return [...acc, curr]; }, []) - .filter(({ target_account }) => target_account !== user.identity.account_number) + .filter(({ target_account }) => target_account !== accountNumber) ); } }); @@ -151,12 +151,12 @@ const ContextSwitcher = ({ user, className }: ContextSwitcherProps) => { searchInputPlaceholder={intl.formatMessage(messages.searchAccount)} isFullHeight > - {user && user?.identity?.account_number?.includes(searchValue) ? ( + {accountNumber?.includes(searchValue) ? ( - {user?.identity?.account_number} - {user?.identity?.account_number === `${selectedAccountNumber}` && ( + {accountNumber} + {accountNumber === `${selectedAccountNumber}` && ( diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 3c7a2a296..ce8731344 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, Suspense, useContext, useState } from 'react'; +import React, { Fragment, Suspense, memo, useContext, useState } from 'react'; import ReactDOM from 'react-dom'; import { useFlag } from '@unleash/proxy-client-react'; import Tools from './Tools'; @@ -36,72 +36,123 @@ const FeedbackRoute = () => { ); }; +function hasUser(user: { orgId?: string; username?: string; accountNumber?: string; email?: string }): user is Required { + return !!(user.orgId && user.username && user.accountNumber && user.email); +} + +const MemoizedHeader = memo( + ({ + breadcrumbsProps, + orgId, + username, + accountNumber, + email, + isOrgAdmin = false, + isInternal = false, + }: { + breadcrumbsProps?: Breadcrumbsprops; + orgId: string; + username: string; + accountNumber: string; + email: string; + isOrgAdmin?: boolean; + isInternal?: boolean; + }) => { + const search = new URLSearchParams(window.location.search).keys().next().value; + const isActivationPath = activationRequestURLs.includes(search); + const { pathname } = useLocation(); + const noBreadcrumb = !['/', '/allservices', '/favoritedservices'].includes(pathname); + const { md, lg } = useWindowWidth(); + const [searchOpen, setSearchOpen] = useState(false); + const hideAllServices = (isOpen: boolean) => { + setSearchOpen(isOpen); + }; + const isITLess = useFlag('platform.chrome.itless'); + + const userReady = hasUser({ orgId, username, accountNumber, email }); + + return ( + + + }> + + + + + + {!lg && } + + + + + + {orgId && !isITLess && ReactDOM.createPortal(, document.body)} + {userReady && isActivationPath && ( + + )} + + + + {userReady && ( + + {!(!md && searchOpen) && } + {isITLess && isOrgAdmin && } + + )} + {userReady && !isITLess && ( + + + + )} + + + + + + + + {lg && } + + + + + {noBreadcrumb && ( + + + + )} + + ); + } +); + +MemoizedHeader.displayName = 'MemoizedHeader'; + export const Header = ({ breadcrumbsProps }: { breadcrumbsProps?: Breadcrumbsprops }) => { + // extract valid data from the context + // we don't want to use the context directly to prevent unnecessary re-renders const { user } = useContext(ChromeAuthContext) as DeepRequired; - const search = new URLSearchParams(window.location.search).keys().next().value; - const isActivationPath = activationRequestURLs.includes(search); - const { pathname } = useLocation(); - const noBreadcrumb = !['/', '/allservices', '/favoritedservices'].includes(pathname); - const { md, lg } = useWindowWidth(); - const [searchOpen, setSearchOpen] = useState(false); - const hideAllServices = (isOpen: boolean) => { - setSearchOpen(isOpen); - }; - const isITLess = useFlag('platform.chrome.itless'); - return ( - - - }> - - - - - - {!lg && } - - - - - - {user?.identity?.org_id && !isITLess && ReactDOM.createPortal(, document.body)} - {user && isActivationPath && } - - - - {user && ( - - {!(!md && searchOpen) && } - {isITLess && user?.identity?.user?.is_org_admin && } - - )} - {user && !isITLess && ( - - - - )} - - - - - - - - {lg && } - - - - - {noBreadcrumb && ( - - - - )} - + ); }; diff --git a/src/components/QuickStart/useQuickstartsStates.stage.test.js b/src/components/QuickStart/useQuickstartsStates.stage.test.js index 7c9d2e846..272092313 100644 --- a/src/components/QuickStart/useQuickstartsStates.stage.test.js +++ b/src/components/QuickStart/useQuickstartsStates.stage.test.js @@ -69,7 +69,7 @@ describe('useQuickstartsStates stage', () => { const wrapper = ({ children }) => {children}; let result; await act(async () => { - const { result: resultInternal } = renderHook(() => useQuickstartsStates(), { wrapper }); + const { result: resultInternal } = renderHook(() => useQuickstartsStates('123'), { wrapper }); result = resultInternal; }); @@ -96,7 +96,7 @@ describe('useQuickstartsStates stage', () => { const wrapper = ({ children }) => {children}; let result; await act(async () => { - const { result: resultInternal } = renderHook(() => useQuickstartsStates(), { wrapper }); + const { result: resultInternal } = renderHook(() => useQuickstartsStates('123'), { wrapper }); result = resultInternal; }); diff --git a/src/components/QuickStart/useQuickstartsStates.ts b/src/components/QuickStart/useQuickstartsStates.ts index 6500eb5ac..34af3d5c0 100644 --- a/src/components/QuickStart/useQuickstartsStates.ts +++ b/src/components/QuickStart/useQuickstartsStates.ts @@ -1,40 +1,43 @@ import axios from 'axios'; -import { useContext, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { QuickStart, QuickStartState } from '@patternfly/quickstarts'; -import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { useSetAtom } from 'jotai'; import { populateQuickstartsAppAtom } from '../../state/atoms/quickstartsAtom'; -const useQuickstartsStates = () => { - const auth = useContext(ChromeAuthContext); - const accountId = auth.user.identity?.internal?.account_id; +const useQuickstartsStates = (accountId?: string) => { const populateQuickstarts = useSetAtom(populateQuickstartsAppAtom); const [allQuickStartStates, setAllQuickStartStatesInternal] = useState<{ [key: string | number]: QuickStartState }>({}); const [activeQuickStartID, setActiveQuickStartIDInternal] = useState(''); - function setAllQuickStartStates(value: QuickStartState | ((states: typeof allQuickStartStates) => QuickStartState)) { - const valueToStore = typeof value === 'function' ? value(allQuickStartStates) : value; - const activeState = valueToStore[activeQuickStartID]; + const setAllQuickStartStates = useCallback( + (value: QuickStartState | ((states: typeof allQuickStartStates) => QuickStartState)) => { + const valueToStore = typeof value === 'function' ? value(allQuickStartStates) : value; + const activeState = valueToStore[activeQuickStartID]; - if (typeof activeState === 'object') { - axios - .post('/api/quickstarts/v1/progress', { - quickstartName: activeQuickStartID, - accountId: parseInt(accountId!), - progress: activeState, - }) - .catch((err) => { - console.error(`Unable to persis quickstart progress! ${activeQuickStartID}`, err); - }); - } - setAllQuickStartStatesInternal(value as unknown as typeof allQuickStartStates); - } + if (typeof activeState === 'object') { + axios + .post('/api/quickstarts/v1/progress', { + quickstartName: activeQuickStartID, + accountId: parseInt(accountId!), + progress: activeState, + }) + .catch((err) => { + console.error(`Unable to persis quickstart progress! ${activeQuickStartID}`, err); + }); + } + setAllQuickStartStatesInternal(value as unknown as typeof allQuickStartStates); + }, + [setAllQuickStartStatesInternal, activeQuickStartID, accountId] + ); - function setActiveQuickStartID(id: string) { - id !== '' && typeof id !== 'function' ? document.body.classList.add('quickstarts-open') : document.body.classList.remove('quickstarts-open'); - setActiveQuickStartIDInternal(id); - } + const setActiveQuickStartID = useCallback( + (id: string) => { + id !== '' && typeof id !== 'function' ? document.body.classList.add('quickstarts-open') : document.body.classList.remove('quickstarts-open'); + setActiveQuickStartIDInternal(id); + }, + [setActiveQuickStartIDInternal] + ); useEffect(() => { if (accountId) { @@ -60,25 +63,28 @@ const useQuickstartsStates = () => { } }, [accountId]); - async function activateQuickstart(name: string) { - try { - const { - data: { data }, - } = await axios.get<{ data: { content: QuickStart }[] }>('/api/quickstarts/v1/quickstarts', { - params: { - name, - }, - }); - populateQuickstarts({ - app: 'default', - quickstarts: data.map(({ content }) => content), - }); + const activateQuickstart = useCallback( + async (name: string) => { + try { + const { + data: { data }, + } = await axios.get<{ data: { content: QuickStart }[] }>('/api/quickstarts/v1/quickstarts', { + params: { + name, + }, + }); + populateQuickstarts({ + app: 'default', + quickstarts: data.map(({ content }) => content), + }); - setActiveQuickStartID(name); - } catch (error) { - console.error('Unable to active quickstarts called: ', name, error); - } - } + setActiveQuickStartID(name); + } catch (error) { + console.error('Unable to active quickstarts called: ', name, error); + } + }, + [populateQuickstarts, setActiveQuickStartID] + ); useEffect(() => { // this hook is above the router node this the window location usage @@ -90,13 +96,19 @@ const useQuickstartsStates = () => { } }, []); - return { - activateQuickstart, - allQuickStartStates, - setAllQuickStartStates, - activeQuickStartID, - setActiveQuickStartID, - }; + // make sure the API is not created on every render + const quickstartState = useMemo( + () => ({ + activateQuickstart, + allQuickStartStates, + setAllQuickStartStates, + activeQuickStartID, + setActiveQuickStartID, + }), + [activateQuickstart, allQuickStartStates, setAllQuickStartStates, activeQuickStartID, setActiveQuickStartID] + ); + + return quickstartState; }; export default useQuickstartsStates; diff --git a/src/components/RootApp/RootApp.tsx b/src/components/RootApp/RootApp.tsx index 5c2b5786d..eee153f94 100644 --- a/src/components/RootApp/RootApp.tsx +++ b/src/components/RootApp/RootApp.tsx @@ -23,9 +23,10 @@ import { addQuickstartToAppAtom, clearQuickstartsAtom, populateQuickstartsAppAto const NotEntitledModal = lazy(() => import('../NotEntitledModal')); const Debugger = lazy(() => import('../Debugger')); -const RootApp = memo(() => { +const RootApp = memo(({ accountId }: { accountId?: string }) => { const config = useAtomValue(scalprumConfigAtom); - const { activateQuickstart, allQuickStartStates, setAllQuickStartStates, activeQuickStartID, setActiveQuickStartID } = useQuickstartsStates(); + const { activateQuickstart, allQuickStartStates, setAllQuickStartStates, activeQuickStartID, setActiveQuickStartID } = + useQuickstartsStates(accountId); const { helpTopics, addHelpTopics, disableTopics, enableTopics } = useHelpTopicState(); const activeModule = useAtomValue(activeModuleAtom); const quickstartsData = useAtomValue(quickstartsAtom); @@ -33,11 +34,6 @@ const RootApp = memo(() => { const clearQuickstarts = useSetAtom(clearQuickstartsAtom); const populateQuickstarts = useSetAtom(populateQuickstartsAppAtom); const addQuickstartToApp = useSetAtom(addQuickstartToAppAtom); - const { user } = useContext(ChromeAuthContext) as DeepRequired; - const isDebuggerEnabled = useAtomValue(isDebuggerEnabledAtom); - - // verify use loged in scopes - useUserSSOScopes(); useEffect(() => { clearQuickstarts(activeQuickStartID); @@ -107,6 +103,7 @@ const RootApp = memo(() => { Catalog: LazyQuickStartCatalog, updateQuickStarts, }; + return ( @@ -115,9 +112,7 @@ const RootApp = memo(() => { - - {user?.identity?.account_number && !ITLess() && isDebuggerEnabled && ReactDOM.createPortal(, document.body)} - + @@ -131,4 +126,20 @@ const RootApp = memo(() => { RootApp.displayName = 'MemoizedRootApp'; -export default RootApp; +const AuthRoot = () => { + const { user, login } = useContext(ChromeAuthContext) as DeepRequired; + const isDebuggerEnabled = useAtomValue(isDebuggerEnabledAtom); + + // verify use loged in scopes + useUserSSOScopes(login); + return ( + <> + + {user?.identity?.account_number && !ITLess() && isDebuggerEnabled && ReactDOM.createPortal(, document.body)} + + + + ); +}; + +export default AuthRoot; diff --git a/src/components/RootApp/ScalprumRoot.tsx b/src/components/RootApp/ScalprumRoot.tsx index 11de4100d..764d2b5ff 100644 --- a/src/components/RootApp/ScalprumRoot.tsx +++ b/src/components/RootApp/ScalprumRoot.tsx @@ -4,7 +4,6 @@ import { ScalprumProvider, ScalprumProviderProps } from '@scalprum/react-core'; import { shallowEqual, useSelector, useStore } from 'react-redux'; import { Route, Routes } from 'react-router-dom'; import { HelpTopic, HelpTopicContext } from '@patternfly/quickstarts'; -import isEqual from 'lodash/isEqual'; import { AppsConfig } from '@scalprum/core'; import { ChromeAPI, EnableTopicsArgs } from '@redhat-cloud-services/types'; import { ChromeProvider } from '@redhat-cloud-services/chrome'; @@ -37,6 +36,8 @@ import { NotificationData, notificationDrawerDataAtom } from '../../state/atoms/ import { isPreviewAtom } from '../../state/atoms/releaseAtom'; import { addNavListenerAtom, deleteNavListenerAtom } from '../../state/atoms/activeAppAtom'; import BetaSwitcher from '../BetaSwitcher'; +import useHandlePendoScopeUpdate from '../../hooks/useHandlePendoScopeUpdate'; +import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; const ProductSelection = lazy(() => import('../Stratosphere/ProductSelection')); @@ -45,227 +46,234 @@ const useGlobalFilter = (callback: (selectedTags?: FlagTagsFilter) => any) => { return callback(selectedTags); }; -export type ScalprumRootProps = { +const ScalprumRoot = memo( + () => { + return ( + + + + } />} /> + + + + } + /> + + } /> + + } + /> + {!ITLess() && ( + + } /> + + } + /> + )} + } /> + } /> + + + ); + // no props, no need to ever render based on parent changes + }, + () => true +); + +ScalprumRoot.displayName = 'MemoizedScalprumRoot'; + +export type ChromeApiRootProps = { config: AppsConfig; helpTopicsAPI: HelpTopicsAPI; quickstartsAPI: QuickstartsApi; }; -const ScalprumRoot = memo( - ({ config, helpTopicsAPI, quickstartsAPI, ...props }: ScalprumRootProps) => { - const { setFilteredHelpTopics } = useContext(HelpTopicContext); - const internalFilteredTopics = useRef([]); - const { analytics } = useContext(SegmentContext); - const chromeAuth = useContext(ChromeAuthContext); - const registerModule = useSetAtom(onRegisterModuleWriteAtom); - const populateNotifications = useSetAtom(notificationDrawerDataAtom); - const isPreview = useAtomValue(isPreviewAtom); - const addNavListener = useSetAtom(addNavListenerAtom); - const deleteNavListener = useSetAtom(deleteNavListenerAtom); +const ChromeApiRoot = ({ config, helpTopicsAPI, quickstartsAPI }: ChromeApiRootProps) => { + const chromeAuth = useContext(ChromeAuthContext); + const mutableChromeApi = useRef(); + const isPreview = useAtomValue(isPreviewAtom); + const addNavListener = useSetAtom(addNavListenerAtom); + const deleteNavListener = useSetAtom(deleteNavListenerAtom); + const { setFilteredHelpTopics } = useContext(HelpTopicContext); + const internalFilteredTopics = useRef([]); + const { analytics } = useContext(SegmentContext); + const registerModule = useSetAtom(onRegisterModuleWriteAtom); + const store = useStore(); + const activeModule = useAtomValue(activeModuleAtom); - const store = useStore(); - const mutableChromeApi = useRef(); + // initialize WS event handling + const addWsEventListener = useChromeServiceEvents(); - // initialize WS event handling - const addWsEventListener = useChromeServiceEvents(); - // track pendo usage - useTrackPendoUsage(); - // setting default tab title - useTabName(); + // track bundle visits + useBundleVisitDetection(chromeAuth.user?.identity?.internal?.org_id); - async function getNotifications() { - try { - const { data } = await axios.get<{ data: NotificationData[] }>(`/api/notifications/v1/notifications/drawer`, { - params: { - limit: 50, - sort_by: 'read:asc', - startDate: getSevenDaysAgo(), - }, - }); - populateNotifications(data?.data || []); - } catch (error) { - console.error('Unable to get Notifications ', error); - } - } + // track pendo usage + useTrackPendoUsage(); + // update pendo data on scope change + useHandlePendoScopeUpdate(chromeAuth.user, activeModule); + // setting default tab title + useTabName(); - const { setActiveTopic } = useHelpTopicManager(helpTopicsAPI); + const populateNotifications = useSetAtom(notificationDrawerDataAtom); - function isStringArray(arr: EnableTopicsArgs): arr is string[] { - return typeof arr[0] === 'string'; - } - async function enableTopics(...names: EnableTopicsArgs) { - let internalNames: string[] = []; - let shouldAppend = false; - if (isStringArray(names)) { - internalNames = names; - } else { - internalNames = names[0].names; - shouldAppend = !!names[0].append; - } - return helpTopicsAPI.enableTopics(...internalNames).then((res) => { - internalFilteredTopics.current = shouldAppend - ? [...internalFilteredTopics.current, ...res.filter((topic) => !internalFilteredTopics.current.find(({ name }) => name === topic.name))] - : res; - setFilteredHelpTopics?.(internalFilteredTopics.current); - return res; + async function getNotifications() { + try { + const { data } = await axios.get<{ data: NotificationData[] }>(`/api/notifications/v1/notifications/drawer`, { + params: { + limit: 50, + sort_by: 'read:asc', + startDate: getSevenDaysAgo(), + }, }); + populateNotifications(data?.data || []); + } catch (error) { + console.error('Unable to get Notifications ', error); } + } - function disableTopics(...topicsNames: string[]) { - helpTopicsAPI.disableTopics(...topicsNames); - internalFilteredTopics.current = internalFilteredTopics.current.filter((topic) => !topicsNames.includes(topic.name)); - setFilteredHelpTopics?.(internalFilteredTopics.current); - } + useEffect(() => { + // prepare webpack module sharing scope overrides + updateSharedScope(); + // get notifications drawer api + getNotifications(); + const unregister = chromeHistory.listen(historyListener); + return () => { + if (typeof unregister === 'function') { + return unregister(); + } + }; + }, []); - // track bundle visits - useBundleVisitDetection(); + const setPageMetadata = useCallback((pageOptions: any) => { + window._segment = { + ...window._segment, + pageOptions, + }; + }, []); - useEffect(() => { - // prepare webpack module sharing scope overrides - updateSharedScope(); - // get notifications drawer api - getNotifications(); - const unregister = chromeHistory.listen(historyListener); - return () => { - if (typeof unregister === 'function') { - return unregister(); - } - }; - }, []); + const { setActiveTopic } = useHelpTopicManager(helpTopicsAPI); - const setPageMetadata = useCallback((pageOptions: any) => { - window._segment = { - ...window._segment, - pageOptions, - }; - }, []); + function isStringArray(arr: EnableTopicsArgs): arr is string[] { + return typeof arr[0] === 'string'; + } + async function enableTopics(...names: EnableTopicsArgs) { + let internalNames: string[] = []; + let shouldAppend = false; + if (isStringArray(names)) { + internalNames = names; + } else { + internalNames = names[0].names; + shouldAppend = !!names[0].append; + } + return helpTopicsAPI.enableTopics(...internalNames).then((res) => { + internalFilteredTopics.current = shouldAppend + ? [...internalFilteredTopics.current, ...res.filter((topic) => !internalFilteredTopics.current.find(({ name }) => name === topic.name))] + : res; + setFilteredHelpTopics?.(internalFilteredTopics.current); + return res; + }); + } - const helpTopicsChromeApi = useMemo( - () => ({ - ...helpTopicsAPI, - setActiveTopic, - enableTopics, - disableTopics, - closeHelpTopic: () => { - setActiveTopic(''); - }, - }), - [] - ); + function disableTopics(...topicsNames: string[]) { + helpTopicsAPI.disableTopics(...topicsNames); + internalFilteredTopics.current = internalFilteredTopics.current.filter((topic) => !topicsNames.includes(topic.name)); + setFilteredHelpTopics?.(internalFilteredTopics.current); + } - useMemo(() => { - mutableChromeApi.current = createChromeContext({ - analytics: analytics!, - helpTopics: helpTopicsChromeApi, - quickstartsAPI, - useGlobalFilter, - store, - setPageMetadata, - chromeAuth, - registerModule, - isPreview, - addNavListener, - deleteNavListener, - addWsEventListener, - }); - // reset chrome object after token (user) updates/changes - }, [chromeAuth.token, isPreview]); + const helpTopicsChromeApi = useMemo( + () => ({ + ...helpTopicsAPI, + setActiveTopic, + enableTopics, + disableTopics, + closeHelpTopic: () => { + setActiveTopic(''); + }, + }), + [] + ); - const scalprumProviderProps: ScalprumProviderProps<{ chrome: ChromeAPI }> = useMemo(() => { - if (!mutableChromeApi.current) { - throw new Error('Chrome API failed to initialize.'); - } - // set the deprecated chrome API to window - // eslint-disable-next-line rulesdir/no-chrome-api-call-from-window - window.insights.chrome = chromeApiWrapper(mutableChromeApi.current); - return { - config, - api: { - chrome: mutableChromeApi.current, - }, - pluginSDKOptions: { - pluginLoaderOptions: { - // sharedScope: scope, - transformPluginManifest: (manifest) => { - if (manifest.name === 'chrome') { - return { - ...manifest, - // Do not include chrome chunks in manifest for chrome. It will result in an infinite loading loop - // window.chrome always exists because chrome container is always initialized - loadScripts: [], - }; - } - const newManifest = { + useMemo(() => { + mutableChromeApi.current = createChromeContext({ + analytics: analytics!, + helpTopics: helpTopicsChromeApi, + quickstartsAPI, + useGlobalFilter, + store, + setPageMetadata, + chromeAuth, + registerModule, + isPreview, + addNavListener, + deleteNavListener, + addWsEventListener, + }); + }, [isPreview]); + + if (!mutableChromeApi.current) { + return null; + } + + const scalprumProviderProps: ScalprumProviderProps<{ chrome: ChromeAPI }> = useMemo(() => { + if (!mutableChromeApi.current) { + throw new Error('Chrome API failed to initialize.'); + } + // set the deprecated chrome API to window + // eslint-disable-next-line rulesdir/no-chrome-api-call-from-window + window.insights.chrome = chromeApiWrapper(mutableChromeApi.current); + return { + config, + api: { + chrome: mutableChromeApi.current, + }, + pluginSDKOptions: { + pluginLoaderOptions: { + // sharedScope: scope, + transformPluginManifest: (manifest) => { + if (manifest.name === 'chrome') { + return { ...manifest, - // Compatibility required for bot pure SDK plugins, HCC plugins and sdk v1/v2 plugins until all are on the same system. - baseURL: manifest.name.includes('hac-') && !manifest.baseURL ? `${isPreview ? '/beta' : ''}/api/plugins/${manifest.name}/` : '/', - loadScripts: manifest.loadScripts?.map((script) => `${manifest.baseURL}${script}`.replace(/\/\//, '/')) ?? [ - `${manifest.baseURL ?? ''}plugin-entry.js`, - ], - registrationMethod: manifest.registrationMethod ?? 'callback', + // Do not include chrome chunks in manifest for chrome. It will result in an infinite loading loop + // window.chrome always exists because chrome container is always initialized + loadScripts: [], }; - return newManifest; - }, + } + const newManifest = { + ...manifest, + // Compatibility required for bot pure SDK plugins, HCC plugins and sdk v1/v2 plugins until all are on the same system. + baseURL: manifest.name.includes('hac-') && !manifest.baseURL ? `${isPreview ? '/beta' : ''}/api/plugins/${manifest.name}/` : '/', + loadScripts: manifest.loadScripts?.map((script) => `${manifest.baseURL}${script}`.replace(/\/\//, '/')) ?? [ + `${manifest.baseURL ?? ''}plugin-entry.js`, + ], + registrationMethod: manifest.registrationMethod ?? 'callback', + }; + return newManifest; }, }, - }; - }, [chromeAuth.token, isPreview]); + }, + }; + }, [isPreview]); - if (!mutableChromeApi.current) { - return null; - } - - return ( - /** - * Once all applications are migrated to chrome 2: - * - define chrome API in chrome root after it mounts - * - copy these functions to window - * - add deprecation warning to the window functions - */ - - - - - - } {...props} />} /> - - - - } - /> - - } /> - - } - /> - {!ITLess() && ( - - } /> - - } - /> - )} - } /> - } /> - - - - - ); - }, - // config rarely changes - (prev, next) => isEqual(prev.config, next.config) -); - -ScalprumRoot.displayName = 'MemoizedScalprumRoot'; + return ( + + + + + + ); +}; -export default ScalprumRoot; +export default ChromeApiRoot; diff --git a/src/hooks/useBundleVisitDetection.ts b/src/hooks/useBundleVisitDetection.ts index 2d72c98d6..e78e5fc00 100644 --- a/src/hooks/useBundleVisitDetection.ts +++ b/src/hooks/useBundleVisitDetection.ts @@ -1,9 +1,8 @@ -import { useContext, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { VisitedBundles, useVisitedBundles } from '@redhat-cloud-services/chrome'; import axios from 'axios'; import { getUrl } from './useBundle'; -import ChromeAuthContext from '../auth/ChromeAuthContext'; // TMP Insights specific trigger const shouldSendVisit = (bundle: string, visits: VisitedBundles) => bundle === 'insights' && !visits[bundle]; @@ -15,10 +14,8 @@ const sendVisitedBundle = async (orgId: string) => { }); }; -const useBundleVisitDetection = () => { +const useBundleVisitDetection = (orgId?: string) => { const { pathname } = useLocation(); - const auth = useContext(ChromeAuthContext); - const orgId = auth.user?.identity?.org_id; const { markVisited, visitedBundles, initialized } = useVisitedBundles(); const bundle = useMemo(() => getUrl('bundle'), [pathname]); useEffect(() => { diff --git a/src/hooks/useHandlePendoScopeUpdate.ts b/src/hooks/useHandlePendoScopeUpdate.ts new file mode 100644 index 000000000..c8f5b2527 --- /dev/null +++ b/src/hooks/useHandlePendoScopeUpdate.ts @@ -0,0 +1,23 @@ +import { ChromeUser } from '@redhat-cloud-services/types'; +import { useEffect } from 'react'; +import { DeepRequired } from 'utility-types'; + +import { getPendoConf } from '../analytics'; +import { useAtomValue } from 'jotai'; +import { isPreviewAtom } from '../state/atoms/releaseAtom'; + +const useHandlePendoScopeUpdate = (user: ChromeUser, scope?: string) => { + const isPreview = useAtomValue(isPreviewAtom); + useEffect(() => { + if (window.pendo) { + try { + window.pendo.updateOptions(getPendoConf(user as DeepRequired, isPreview)); + } catch (error) { + console.error('Unable to update pendo options'); + console.error(error); + } + } + }, [scope]); +}; + +export default useHandlePendoScopeUpdate; diff --git a/src/hooks/useUserSSOScopes.ts b/src/hooks/useUserSSOScopes.ts index be2da5c2e..84934dd8c 100644 --- a/src/hooks/useUserSSOScopes.ts +++ b/src/hooks/useUserSSOScopes.ts @@ -1,5 +1,5 @@ -import { useContext, useEffect } from 'react'; -import ChromeAuthContext from '../auth/ChromeAuthContext'; +import { useEffect } from 'react'; +import { ChromeLogin } from '../auth/ChromeAuthContext'; import { useAtomValue } from 'jotai'; import { activeModuleDefinitionReadAtom } from '../state/atoms/activeModuleAtom'; import shouldReAuthScopes from '../auth/shouldReAuthScopes'; @@ -7,8 +7,7 @@ import shouldReAuthScopes from '../auth/shouldReAuthScopes'; /** * If required, attempt to reauthenticate current user with additional scopes. */ -const useUserSSOScopes = () => { - const { login } = useContext(ChromeAuthContext); +const useUserSSOScopes = (login: ChromeLogin) => { const activeModule = useAtomValue(activeModuleDefinitionReadAtom); // get scope module definition const requiredScopes = activeModule?.config?.ssoScopes || []; diff --git a/src/state/atoms/scalprumConfigAtom.ts b/src/state/atoms/scalprumConfigAtom.ts index 7bba07f24..1be8441e9 100644 --- a/src/state/atoms/scalprumConfigAtom.ts +++ b/src/state/atoms/scalprumConfigAtom.ts @@ -1,5 +1,6 @@ import { atom } from 'jotai'; import { ChromeModule } from '../../@types/types'; +import isEqual from 'lodash/isEqual'; export type ScalprumConfig = { [key: string]: { @@ -15,7 +16,7 @@ export const scalprumConfigAtom = atom({}); export const writeInitialScalprumConfigAtom = atom( null, ( - _get, + get, set, schema: { [key: string]: ChromeModule; @@ -27,16 +28,20 @@ export const writeInitialScalprumConfigAtom = atom( [name]: { name, module: `${name}#./RootApp`, - manifestLocation: `${window.location.origin}${config.manifestLocation}?ts=${Date.now()}`, + manifestLocation: `${window.location.origin}${config.manifestLocation}`, }, }), { chrome: { name: 'chrome', - manifestLocation: `${window.location.origin}/apps/chrome/js/fed-mods.json?ts=${Date.now()}`, + manifestLocation: `${window.location.origin}/apps/chrome/js/fed-mods.json`, }, } ); - set(scalprumConfigAtom, scalprumConfig); + // need to compare the config to prevent unnecessary re-renders, on identity refresh + const prevConfig = get(scalprumConfigAtom); + if (!isEqual(prevConfig, scalprumConfig)) { + set(scalprumConfigAtom, scalprumConfig); + } } );