From 3e3b812f12db9d63d582a9fed1076dfc4688381d Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 14 May 2024 14:26:44 +0200 Subject: [PATCH 1/7] Init internal preview flag from chrome service. --- src/bootstrap.tsx | 37 ++++++++++++++++++++--- src/hooks/useAsyncLoader.ts | 43 +++++++++++++++++++++++++++ src/state/atoms/releaseAtom.ts | 12 ++++++-- src/state/atoms/utils.ts | 3 +- src/state/chromeStore.ts | 2 ++ src/utils/VisibilitySingleton.test.ts | 19 ++++++------ src/utils/VisibilitySingleton.ts | 17 +++++++++-- src/utils/initUserConfig.ts | 39 ++++++++++++++++++++++++ 8 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useAsyncLoader.ts create mode 100644 src/utils/initUserConfig.ts diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index 85fe5ab00..17393c2c5 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { Suspense, useContext, useEffect, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { Provider, useSelector } from 'react-redux'; import { IntlProvider, ReactIntlErrorCode } from 'react-intl'; -import { Provider as JotaiProvider } from 'jotai'; +import { Provider as JotaiProvider, useSetAtom } from 'jotai'; import { spinUpStore } from './redux/redux-config'; import RootApp from './components/RootApp'; @@ -14,6 +14,11 @@ import messages from './locales/data.json'; import ErrorBoundary from './components/ErrorComponents/ErrorBoundary'; import chromeStore from './state/chromeStore'; import { GenerateId } from '@patternfly/react-core/dist/dynamic/helpers/GenerateId/GenerateId'; +import { isPreviewAtom } from './state/atoms/releaseAtom'; +import AppPlaceholder from './components/AppPlaceholder'; +import useAsyncLoader from './hooks/useAsyncLoader'; +import { ChromeUserConfig, initChromeUserConfig } from './utils/initUserConfig'; +import ChromeAuthContext from './auth/ChromeAuthContext'; const isITLessEnv = ITLess(); const language: keyof typeof messages = 'en'; @@ -34,7 +39,14 @@ const useInitializeAnalytics = () => { }, []); }; -const App = () => { +const App = ({ initApp }: { initApp: (...args: Parameters) => ChromeUserConfig | undefined }) => { + const { getUser, token } = useContext(ChromeAuthContext); + // triggers suspense based async call to block rendering until the async call is resolved + // TODO: Most of async init should be moved to this method + initApp({ + getUser, + token, + }); const documentTitle = useSelector(({ chrome }: ReduxState) => chrome?.documentTitle); const [cookieElement, setCookieElement] = useState(null); @@ -48,6 +60,23 @@ const App = () => { return ; }; +const ConfigLoader = () => { + const initPreview = useSetAtom(isPreviewAtom); + function initSuccess(userConfig: ChromeUserConfig) { + initPreview(userConfig.data.uiPreview); + } + function initFail() { + initPreview(false); + } + const { loader } = useAsyncLoader(initChromeUserConfig, initSuccess, initFail); + const [cookieElement, setCookieElement] = useState(null); + return ( + }> + + + ); +}; + const entry = document.getElementById('chrome-entry'); if (entry) { const reactRoot = createRoot(entry); @@ -69,7 +98,7 @@ if (entry) { > - + diff --git a/src/hooks/useAsyncLoader.ts b/src/hooks/useAsyncLoader.ts new file mode 100644 index 000000000..b2d210a0e --- /dev/null +++ b/src/hooks/useAsyncLoader.ts @@ -0,0 +1,43 @@ +import { useRef } from 'react'; + +function useAsyncLoader, T extends Array>( + asyncMethod: (...args: T) => Promise, + afterResolve?: (result: R) => void, + afterReject?: (error: any) => void +) { + const storage = useRef<{ resolved: boolean; rejected: boolean; promise?: Promise; result?: R }>({ + resolved: false, + rejected: false, + promise: undefined, + result: undefined, + }); + + return { + loader: (...args: Parameters) => { + if (storage.current.rejected) return; + + if (storage.current.resolved) return storage.current.result; + + if (storage.current.promise) throw storage.current.promise; + + storage.current.promise = asyncMethod(...args) + .then((res) => { + storage.current.promise = undefined; + storage.current.resolved = true; + storage.current.result = res; + afterResolve?.(res); + return res; + }) + .catch((error) => { + storage.current.promise = undefined; + storage.current.rejected = true; + afterReject?.(error); + return error; + }); + + throw storage.current.promise; + }, + }; +} + +export default useAsyncLoader; diff --git a/src/state/atoms/releaseAtom.ts b/src/state/atoms/releaseAtom.ts index f54332027..c19a48c19 100644 --- a/src/state/atoms/releaseAtom.ts +++ b/src/state/atoms/releaseAtom.ts @@ -1,5 +1,11 @@ +import axios from 'axios'; +import { updateVisibilityFunctionsBeta, visibilityFunctionsExist } from '../../utils/VisibilitySingleton'; import { atomWithToggle } from './utils'; -import { isBeta } from '../../utils/common'; - -export const isPreviewAtom = atomWithToggle(isBeta()); +export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { + // Required to change the `isBeta` function return value in the visibility functions + if (visibilityFunctionsExist()) { + updateVisibilityFunctionsBeta(isPreview); + axios.post('/api/chrome-service/v1/user/update-ui-preview', { uiPreview: isPreview }); + } +}); diff --git a/src/state/atoms/utils.ts b/src/state/atoms/utils.ts index 0bbb74aba..44d912d4f 100644 --- a/src/state/atoms/utils.ts +++ b/src/state/atoms/utils.ts @@ -1,10 +1,11 @@ import { WritableAtom, atom } from 'jotai'; // recipe from https://jotai.org/docs/recipes/atom-with-toggle -export function atomWithToggle(initialValue?: boolean): WritableAtom { +export function atomWithToggle(initialValue?: boolean, onToggle?: (value: boolean) => void): WritableAtom { const anAtom = atom(initialValue, (get, set, nextValue?: boolean) => { const update = nextValue ?? !get(anAtom); set(anAtom, update); + onToggle?.(update); }); return anAtom as WritableAtom; diff --git a/src/state/chromeStore.ts b/src/state/chromeStore.ts index 58de266dc..e727b8f8c 100644 --- a/src/state/chromeStore.ts +++ b/src/state/chromeStore.ts @@ -14,6 +14,8 @@ chromeStore.set(activeModuleAtom, undefined); chromeStore.set(isPreviewAtom, isBeta()); chromeStore.set(gatewayErrorAtom, undefined); chromeStore.set(isFeedbackModalOpenAtom, false); +// is set in bootstrap +chromeStore.set(isPreviewAtom, false); // globally handle subscription to activeModuleAtom chromeStore.sub(activeModuleAtom, () => { diff --git a/src/utils/VisibilitySingleton.test.ts b/src/utils/VisibilitySingleton.test.ts index d8593674d..cf90ad6b5 100644 --- a/src/utils/VisibilitySingleton.test.ts +++ b/src/utils/VisibilitySingleton.test.ts @@ -32,11 +32,12 @@ describe('VisibilitySingleton', () => { const getUserPermissions = jest.fn(); let visibilityFunctions: VisibilityFunctions; - beforeAll(() => { + beforeEach(() => { initializeVisibilityFunctions({ getUser, getToken, getUserPermissions, + isPreview: false, }); visibilityFunctions = getVisibilityFunctions(); }); @@ -162,16 +163,14 @@ describe('VisibilitySingleton', () => { }); test('isBeta', async () => { - const { location } = window; - // @ts-ignore - delete window.location; - // @ts-ignore - window.location = { - pathname: '/beta/insights/foo', - }; - + initializeVisibilityFunctions({ + getUser, + getToken, + getUserPermissions, + isPreview: true, + }); + visibilityFunctions = getVisibilityFunctions(); expect(visibilityFunctions.isBeta()).toBe(true); - window.location = location; }); test('isProd - false', async () => { diff --git a/src/utils/VisibilitySingleton.ts b/src/utils/VisibilitySingleton.ts index 95363c562..5362b084c 100644 --- a/src/utils/VisibilitySingleton.ts +++ b/src/utils/VisibilitySingleton.ts @@ -1,5 +1,5 @@ import { ChromeAPI } from '@redhat-cloud-services/types'; -import { isBeta, isProd } from './common'; +import { isProd } from './common'; import cookie from 'js-cookie'; import axios, { AxiosRequestConfig } from 'axios'; import isEmpty from 'lodash/isEmpty'; @@ -25,10 +25,12 @@ const initialize = ({ getUserPermissions, getUser, getToken, + isPreview, }: { getUser: ChromeAPI['auth']['getUser']; getToken: ChromeAPI['auth']['getToken']; getUserPermissions: ChromeAPI['getUserPermissions']; + isPreview: boolean; }) => { /** * Check if is permitted to see navigation link @@ -81,7 +83,11 @@ const initialize = ({ Object.entries(entitlements || {}).reduce((acc, [key, { is_entitled }]) => ({ ...acc, [key]: is_entitled }), {}); }, isProd: () => isProd(), - isBeta: () => isBeta(), + /** + * @deprecated Should use feature flags instead + * @returns {boolean} + */ + isBeta: () => isPreview, isHidden: () => true, // FIXME: Why always true? withEmail: async (...toHave: string[]) => { const data = await getUser(); @@ -149,4 +155,11 @@ export const getVisibilityFunctions = () => { return visibilityFunctions['*'].get(); }; +export const visibilityFunctionsExist = () => !!getSharedScope()['@chrome/visibilityFunctions']; + +export const updateVisibilityFunctionsBeta = (isPreview: boolean) => { + const visibilityFunctions = getVisibilityFunctions(); + visibilityFunctions.isBeta = () => isPreview; +}; + export const initializeVisibilityFunctions = initialize; diff --git a/src/utils/initUserConfig.ts b/src/utils/initUserConfig.ts new file mode 100644 index 000000000..e9db70611 --- /dev/null +++ b/src/utils/initUserConfig.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; +import { isBeta } from './common'; +import { initializeVisibilityFunctions } from './VisibilitySingleton'; +import createGetUserPermissions from '../auth/createGetUserPermissions'; +import { ChromeUser } from '@redhat-cloud-services/types'; + +export type ChromeUserConfig = { + data: { + uiPreview: boolean; + }; +}; + +export const initChromeUserConfig = async ({ getUser, token }: { getUser: () => Promise; token: string }) => { + const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + let config: ChromeUserConfig; + if (!LOCAL_PREVIEW) { + config = { + data: { + uiPreview: isBeta(), + }, + }; + } else { + const { data } = await axios.get('/api/chrome-service/v1/user', { + params: { + 'skip-identity-cache': 'true', + }, + }); + config = data; + } + + initializeVisibilityFunctions({ + getUser, + getToken: () => Promise.resolve(token), + getUserPermissions: createGetUserPermissions(getUser, () => Promise.resolve(token)), + isPreview: config.data.uiPreview, + }); + + return config; +}; From 38f24b878133e666dcececaed8045f9a2f415c0d Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 14 May 2024 14:33:12 +0200 Subject: [PATCH 2/7] Update unleash preview context on preview toggle. --- src/components/FeatureFlags/FeatureFlagsProvider.tsx | 7 ++++--- src/state/atoms/releaseAtom.ts | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/FeatureFlags/FeatureFlagsProvider.tsx b/src/components/FeatureFlags/FeatureFlagsProvider.tsx index f51a3f2a6..383710144 100644 --- a/src/components/FeatureFlags/FeatureFlagsProvider.tsx +++ b/src/components/FeatureFlags/FeatureFlagsProvider.tsx @@ -3,8 +3,9 @@ import { FlagProvider, IFlagProvider, UnleashClient } from '@unleash/proxy-clien import { DeepRequired } from 'utility-types'; import { captureException } from '@sentry/react'; import * as Sentry from '@sentry/react'; +import { useAtomValue } from 'jotai'; import ChromeAuthContext, { ChromeAuthContextValue } from '../../auth/ChromeAuthContext'; -import { isBeta } from '../../utils/common'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; const config: IFlagProvider['config'] = { url: `${document.location.origin}/api/featureflags/v0`, @@ -63,16 +64,16 @@ export const getFeatureFlagsError = () => localStorage.getItem(UNLEASH_ERROR_KEY const FeatureFlagsProvider: React.FC = ({ children }) => { const { user } = useContext(ChromeAuthContext) as DeepRequired; + const isPreview = useAtomValue(isPreviewAtom); unleashClient = useMemo( () => new UnleashClient({ ...config, context: { - // TODO: instead of the isBeta, use the internal chrome state // the unleash context is not generic, look for issue/PR in the unleash repo or create one // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - 'platform.chrome.ui.preview': isBeta(), + 'platform.chrome.ui.preview': isPreview, userId: user?.identity.internal?.account_id, orgId: user?.identity.internal?.org_id, ...(user diff --git a/src/state/atoms/releaseAtom.ts b/src/state/atoms/releaseAtom.ts index c19a48c19..957e62339 100644 --- a/src/state/atoms/releaseAtom.ts +++ b/src/state/atoms/releaseAtom.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { updateVisibilityFunctionsBeta, visibilityFunctionsExist } from '../../utils/VisibilitySingleton'; import { atomWithToggle } from './utils'; +import { unleashClient } from '../../components/FeatureFlags/FeatureFlagsProvider'; export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { // Required to change the `isBeta` function return value in the visibility functions @@ -8,4 +9,8 @@ export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { updateVisibilityFunctionsBeta(isPreview); axios.post('/api/chrome-service/v1/user/update-ui-preview', { uiPreview: isPreview }); } + // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + unleashClient?.updateContext({ 'platform.chrome.ui.preview': isPreview }); }); From 7b06ef850188c6eed44f251cac1db63f1f7d9bce Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 14 May 2024 14:33:48 +0200 Subject: [PATCH 3/7] Use internal preview state where ever possible. --- src/analytics/SegmentProvider.tsx | 24 ++++--- src/analytics/analytics.test.ts | 6 +- src/analytics/index.ts | 15 ++-- src/auth/OIDCConnector/OIDCProvider.tsx | 4 +- src/auth/OIDCConnector/OIDCSecured.tsx | 7 -- src/auth/OIDCConnector/utils.ts | 1 + src/chrome/create-chrome.test.ts | 2 + src/chrome/create-chrome.ts | 6 +- .../AllServicesDropdown/AllServicesTabs.tsx | 6 +- src/components/AppFilter/useAppFilter.ts | 19 +++-- src/components/ChromeRoute/ChromeRoute.tsx | 4 +- src/components/Feedback/FeedbackModal.tsx | 10 ++- src/components/Header/HeaderAlert.tsx | 3 + .../Header/HeaderTests/Tools.test.js | 28 +++++++- src/components/Header/PreviewAlert.tsx | 61 ++++++++++++++++ src/components/Header/SettingsToggle.tsx | 6 +- src/components/Header/ToolbarToggle.tsx | 6 +- src/components/Header/Tools.tsx | 70 ++++++++----------- src/components/Navigation/ChromeNavItem.tsx | 6 +- src/components/Navigation/index.tsx | 8 ++- src/components/RootApp/ScalprumRoot.test.js | 12 +++- src/components/RootApp/ScalprumRoot.tsx | 13 ++-- src/hooks/useAllLinks.ts | 9 ++- src/hooks/useFavoritedServices.ts | 5 +- src/index.ts | 5 ++ src/layouts/DefaultLayout.test.js | 8 +++ src/state/atoms/scalprumConfigAtom.ts | 9 ++- src/utils/cache.ts | 2 + src/utils/common.ts | 13 +++- src/utils/createCase.ts | 20 +++--- src/utils/fetchNavigationFiles.ts | 16 +++-- src/utils/useNavigation.ts | 12 +++- src/utils/usePreviewFlag.ts | 12 ---- 33 files changed, 289 insertions(+), 139 deletions(-) create mode 100644 src/components/Header/PreviewAlert.tsx delete mode 100644 src/utils/usePreviewFlag.ts diff --git a/src/analytics/SegmentProvider.tsx b/src/analytics/SegmentProvider.tsx index 244083b1e..93e7b0d45 100644 --- a/src/analytics/SegmentProvider.tsx +++ b/src/analytics/SegmentProvider.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useRef } from 'react'; import { AnalyticsBrowser } from '@segment/analytics-next'; import Cookie from 'js-cookie'; -import { ITLess, isBeta, isProd } from '../utils/common'; +import { ITLess, isProd } from '../utils/common'; import { ChromeUser } from '@redhat-cloud-services/types'; import { useLocation } from 'react-router-dom'; import axios from 'axios'; @@ -11,6 +11,7 @@ import { getUrl } from '../hooks/useBundle'; import ChromeAuthContext from '../auth/ChromeAuthContext'; import { useAtomValue } from 'jotai'; import { activeModuleAtom, activeModuleDefinitionReadAtom } from '../state/atoms/activeModuleAtom'; +import { isPreviewAtom } from '../state/atoms/releaseAtom'; type SegmentEnvs = 'dev' | 'prod'; type SegmentModules = 'acs' | 'openshift' | 'hacCore'; @@ -31,7 +32,7 @@ function getAdobeVisitorId() { return -1; } -const getPageEventOptions = () => { +const getPageEventOptions = (isPreview: boolean) => { const path = window.location.pathname.replace(/^\/(beta\/|preview\/|beta$|preview$)/, '/'); const search = new URLSearchParams(window.location.search); @@ -46,7 +47,7 @@ const getPageEventOptions = () => { { path, url: `${window.location.origin}${path}${window.location.search}`, - isBeta: isBeta(), + isBeta: isPreview, module: window._segment?.activeModule, // Marketing campaing tracking ...trackingContext, @@ -77,7 +78,7 @@ const getAPIKey = (env: SegmentEnvs = 'dev', module: SegmentModules, moduleAPIKe KEY_FALLBACK[env]; let observer: MutationObserver | undefined; -const registerAnalyticsObserver = () => { +const registerAnalyticsObserver = (isPreview: boolean) => { // never override the observer if (observer) { return; @@ -96,7 +97,7 @@ const registerAnalyticsObserver = () => { oldHref = newLocation; window?.sendCustomEvent?.('pageBottom'); setTimeout(() => { - window.segment?.page(...getPageEventOptions()); + window.segment?.page(...getPageEventOptions(isPreview)); }); } }); @@ -113,7 +114,7 @@ const emailDomain = (email = '') => (/@/g.test(email) ? email.split('@')[1].toLo const getPagePathSegment = (pathname: string, n: number) => pathname.split('/')[n] || ''; -const getIdentityTraits = (user: ChromeUser, pathname: string, activeModule = '') => { +const getIdentityTraits = (user: ChromeUser, pathname: string, activeModule = '', isPreview: boolean) => { const entitlements = Object.entries(user.entitlements).reduce( (acc, [key, entitlement]) => ({ ...acc, @@ -132,7 +133,7 @@ const getIdentityTraits = (user: ChromeUser, pathname: string, activeModule = '' isOrgAdmin: user.identity.user?.is_org_admin, currentBundle: getUrl('bundle'), currentApp: activeModule, - isBeta: isBeta(), + isBeta: isPreview, ...(user.identity.user ? { name: `${user.identity.user.first_name} ${user.identity.user.last_name}`, @@ -158,6 +159,7 @@ const SegmentProvider: React.FC = ({ children }) => { const analytics = useRef(); const analyticsLoaded = useRef(false); const { user } = useContext(ChromeAuthContext); + const isPreview = useAtomValue(isPreviewAtom); const activeModule = useAtomValue(activeModuleAtom); const activeModuleDefinition = useAtomValue(activeModuleDefinitionReadAtom); @@ -198,7 +200,7 @@ const SegmentProvider: React.FC = ({ children }) => { activeModule, }; const newKey = getAPIKey(DEV_ENV ? 'dev' : 'prod', activeModule as SegmentModules, moduleAPIKey); - const identityTraits = getIdentityTraits(user, pathname, activeModule); + const identityTraits = getIdentityTraits(user, pathname, activeModule, isPreview); const identityOptions = { context: { groupId: user.identity.internal?.org_id, @@ -226,7 +228,7 @@ const SegmentProvider: React.FC = ({ children }) => { }, }); analytics.current.group(user.identity.internal?.org_id, groupTraits); - analytics.current.page(...getPageEventOptions()); + analytics.current.page(...getPageEventOptions(isPreview)); initialized.current = true; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore TS does not allow accessing the instance settings but its necessary for us to not create instances if we don't have to @@ -259,12 +261,12 @@ const SegmentProvider: React.FC = ({ children }) => { }; useEffect(() => { - registerAnalyticsObserver(); + registerAnalyticsObserver(isPreview); return () => { observer?.disconnect(); observer = undefined; }; - }, []); + }, [isPreview]); useEffect(() => { handleModuleUpdate(); diff --git a/src/analytics/analytics.test.ts b/src/analytics/analytics.test.ts index 8e886e64d..4b6f4e092 100644 --- a/src/analytics/analytics.test.ts +++ b/src/analytics/analytics.test.ts @@ -33,7 +33,7 @@ function buildUser(token: any): DeepRequired { describe('User + Analytics', () => { describe('buildUser + getPendoConf internal', () => { test('should build a valid internal Pendo config', () => { - const conf = getPendoConf(buildUser(token)); + const conf = getPendoConf(buildUser(token), false); expect(conf).toMatchObject({ account: { id: '540155', @@ -47,7 +47,7 @@ describe('User + Analytics', () => { }); test('should build a valid external Pendo config', () => { - const conf = getPendoConf(buildUser(externalToken)); + const conf = getPendoConf(buildUser(externalToken), false); expect(conf).toMatchObject({ account: { id: '540155', @@ -61,7 +61,7 @@ describe('User + Analytics', () => { }); test('should build a valid IBM pendo config', () => { - const conf = getPendoConf(buildUser(ibmToken)); + const conf = getPendoConf(buildUser(ibmToken), false); expect(conf).toMatchObject({ account: { id: '540155', diff --git a/src/analytics/index.ts b/src/analytics/index.ts index 3c7f599c3..cbd78a516 100644 --- a/src/analytics/index.ts +++ b/src/analytics/index.ts @@ -1,4 +1,4 @@ -import { isBeta, isProd } from '../utils/common'; +import { isProd } from '../utils/common'; import { ChromeUser } from '@redhat-cloud-services/types'; import { DeepRequired } from 'utility-types'; @@ -14,23 +14,22 @@ function isInternalFlag(email: string, isInternal = false) { return ''; } -function getUrl(type?: string) { +function getUrl(type?: string, isPreview = false) { if (['/beta', '/preview', '/'].includes(window.location.pathname)) { return 'landing'; } const sections = window.location.pathname.split('/').slice(1); - const isBetaEnv = isBeta(); if (type) { - if (isBetaEnv) { + if (isPreview) { return type === 'bundle' ? sections[1] : sections[2]; } return type === 'bundle' ? sections[0] : sections[1]; } - isBetaEnv && sections.shift(); - return [isBetaEnv, ...sections]; + isPreview && sections.shift(); + return [isPreview, ...sections]; } function getAdobeVisitorId() { @@ -42,7 +41,7 @@ function getAdobeVisitorId() { return -1; } -export function getPendoConf(data: DeepRequired) { +export function getPendoConf(data: DeepRequired, isPreview: boolean) { const userID = `${data.identity.internal.account_id}${isInternalFlag(data.identity.user.email, data.identity.user.is_internal)}`; const entitlements: Record = {}; @@ -53,7 +52,7 @@ export function getPendoConf(data: DeepRequired) { entitlements[`entitlements_${key}_trial`] = value.is_trial; }); - const [isBeta, currentBundle, currentApp, ...rest] = getUrl(); + const [isBeta, currentBundle, currentApp, ...rest] = getUrl(undefined, isPreview); return { visitor: { diff --git a/src/auth/OIDCConnector/OIDCProvider.tsx b/src/auth/OIDCConnector/OIDCProvider.tsx index 8b8e63d5f..bdac4000c 100644 --- a/src/auth/OIDCConnector/OIDCProvider.tsx +++ b/src/auth/OIDCConnector/OIDCProvider.tsx @@ -8,7 +8,9 @@ import AppPlaceholder from '../../components/AppPlaceholder'; import { postbackUrlSetup } from '../offline'; import OIDCStateReloader from './OIDCStateReloader'; -const betaPartial = isBeta() ? '/beta' : ''; +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; +// TODO: remove this once the local preview is enabled by default +const betaPartial = LOCAL_PREVIEW ? '' : isBeta() ? '/beta' : ''; const OIDCProvider: React.FC = ({ children }) => { const [cookieElement, setCookieElement] = useState(null); diff --git a/src/auth/OIDCConnector/OIDCSecured.tsx b/src/auth/OIDCConnector/OIDCSecured.tsx index 6bee06fa3..ef89cfba8 100644 --- a/src/auth/OIDCConnector/OIDCSecured.tsx +++ b/src/auth/OIDCConnector/OIDCSecured.tsx @@ -8,13 +8,11 @@ import { generateRoutesList } from '../../utils/common'; import getInitialScope from '../getInitialScope'; import { init } from '../../utils/iqeEnablement'; import entitlementsApi from '../entitlementsApi'; -import { initializeVisibilityFunctions } from '../../utils/VisibilitySingleton'; import sentry from '../../utils/sentry'; import AppPlaceholder from '../../components/AppPlaceholder'; import { FooterProps } from '../../components/Footer/Footer'; import logger from '../logger'; import { login, logout } from './utils'; -import createGetUserPermissions from '../createGetUserPermissions'; import initializeAccessRequestCookies from '../initializeAccessRequestCookies'; import { getOfflineToken, prepareOfflineRedirect } from '../offline'; import { OFFLINE_REDIRECT_STORAGE_KEY } from '../../utils/consts'; @@ -151,11 +149,6 @@ export function OIDCSecured({ const entitlements = await fetchEntitlements(user); const chromeUser = mapOIDCUserToChromeUser(user, entitlements); const getUser = () => Promise.resolve(chromeUser); - initializeVisibilityFunctions({ - getUser, - getToken: () => Promise.resolve(user.access_token), - getUserPermissions: createGetUserPermissions(getUser, () => Promise.resolve(user.access_token)), - }); setState((prev) => ({ ...prev, ready: true, diff --git a/src/auth/OIDCConnector/utils.ts b/src/auth/OIDCConnector/utils.ts index 2adb905e8..8a319fc12 100644 --- a/src/auth/OIDCConnector/utils.ts +++ b/src/auth/OIDCConnector/utils.ts @@ -44,6 +44,7 @@ export async function logout(auth: AuthContextProps, bounce?: boolean) { key.startsWith(GLOBAL_FILTER_KEY) ); deleteLocalStorageItems([...keys, OFFLINE_REDIRECT_STORAGE_KEY, LOGIN_SCOPES_STORAGE_KEY]); + // FIXME: Remove this one local preview is enabled by default const pathname = isBeta() ? getRouterBasename() : ''; if (bounce) { const eightSeconds = new Date(new Date().getTime() + 8 * 1000); diff --git a/src/chrome/create-chrome.test.ts b/src/chrome/create-chrome.test.ts index 82b7fee65..0f07ad858 100644 --- a/src/chrome/create-chrome.test.ts +++ b/src/chrome/create-chrome.test.ts @@ -104,6 +104,7 @@ describe('create chrome', () => { enableTopics: jest.fn(), setActiveTopic: jest.fn(), }, + isPreview: false, quickstartsAPI: { Catalog: QuickStartCatalog, set() { @@ -126,6 +127,7 @@ describe('create chrome', () => { getUser: () => Promise.resolve(mockUser), getToken: () => Promise.resolve('mocked-token'), getUserPermissions: () => Promise.resolve([]), + isPreview: false, }; initializeVisibilityFunctions(mockAuthMethods); }); diff --git a/src/chrome/create-chrome.ts b/src/chrome/create-chrome.ts index 137624c04..441281adf 100644 --- a/src/chrome/create-chrome.ts +++ b/src/chrome/create-chrome.ts @@ -16,7 +16,7 @@ import { toggleDebuggerModal, toggleGlobalFilter, } from '../redux/actions'; -import { ITLess, getEnv, getEnvDetails, isBeta, isProd, updateDocumentTitle } from '../utils/common'; +import { ITLess, getEnv, getEnvDetails, isProd, updateDocumentTitle } from '../utils/common'; import { createSupportCase } from '../utils/createCase'; import debugFunctions from '../utils/debugFunctions'; import { flatTags } from '../components/GlobalFilter/globalFilterApi'; @@ -47,6 +47,7 @@ export type CreateChromeContextConfig = { helpTopics: ChromeAPI['helpTopics']; chromeAuth: ChromeAuthContextValue; registerModule: (payload: RegisterModulePayload) => void; + isPreview: boolean; }; export const createChromeContext = ({ @@ -58,6 +59,7 @@ export const createChromeContext = ({ helpTopics, registerModule, chromeAuth, + isPreview, }: CreateChromeContextConfig): ChromeAPI => { const fetchPermissions = createFetchPermissionsWatcher(chromeAuth.getUser); const visibilityFunctions = getVisibilityFunctions(); @@ -154,7 +156,7 @@ export const createChromeContext = ({ } dispatch(toggleGlobalFilter(isHidden)); }, - isBeta, + isBeta: () => isPreview, isChrome2: true, enable: debugFunctions, isDemo: () => Boolean(Cookies.get('cs_demo')), diff --git a/src/components/AllServicesDropdown/AllServicesTabs.tsx b/src/components/AllServicesDropdown/AllServicesTabs.tsx index 66fbf1a0b..e26bb3cfc 100644 --- a/src/components/AllServicesDropdown/AllServicesTabs.tsx +++ b/src/components/AllServicesDropdown/AllServicesTabs.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef } from 'react'; +import { useAtomValue } from 'jotai'; import { Icon } from '@patternfly/react-core/dist/dynamic/components/Icon'; import { Tab, TabProps, TabTitleText, Tabs, TabsProps } from '@patternfly/react-core/dist/dynamic/components/Tabs'; @@ -6,12 +7,12 @@ import StarIcon from '@patternfly/react-icons/dist/dynamic/icons/star-icon'; import { FAVORITE_TAB_ID, TAB_CONTENT_ID } from './common'; import type { AllServicesSection as AllServicesSectionType } from '../AllServices/allServicesLinks'; -import { isBeta } from '../../utils/common'; import { Divider } from '@patternfly/react-core/dist/dynamic/components/Divider'; import { Text, TextVariants } from '@patternfly/react-core/dist/dynamic/components/Text'; import ChromeLink from '../ChromeLink'; import './AllServicesTabs.scss'; import PlatformServiceslinks from './PlatformServicesLinks'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type AllServicesTabsProps = { activeTabKey: string | number; @@ -27,6 +28,7 @@ export type AllServicesTabsProps = { type TabWrapper = Omit; const TabWrapper = (props: TabWrapper) => { + const isPreview = useAtomValue(isPreviewAtom); const tabRef = useRef(null); const hoverTimer = useRef(undefined); const stopHoverEffect = () => { @@ -40,7 +42,7 @@ const TabWrapper = (props: TabWrapper) => { const timeout = setTimeout(() => { // should be available only in preview // use refs to supply the required tab events - isBeta() && tabRef.current?.click(); + isPreview && tabRef.current?.click(); }, 300); hoverTimer.current = timeout; }; diff --git a/src/components/AppFilter/useAppFilter.ts b/src/components/AppFilter/useAppFilter.ts index 75defc33e..ae894ed76 100644 --- a/src/components/AppFilter/useAppFilter.ts +++ b/src/components/AppFilter/useAppFilter.ts @@ -3,10 +3,13 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { BundleNavigation, ChromeModule, NavItem } from '../../@types/types'; import { ReduxState } from '../../redux/store'; -import { getChromeStaticPathname, isBeta, isProd } from '../../utils/common'; +import { getChromeStaticPathname } from '../../utils/common'; import { evaluateVisibility } from '../../utils/isNavItemVisible'; import { useAtomValue } from 'jotai'; import { chromeModulesAtom } from '../../state/atoms/chromeModuleAtom'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; + +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; export type AppFilterBucket = { id: string; @@ -14,8 +17,6 @@ export type AppFilterBucket = { links: NavItem[]; }; -const previewBundles = ['']; - export const requiredBundles = [ 'application-services', 'openshift', @@ -26,8 +27,7 @@ export const requiredBundles = [ 'iam', 'quay', 'subscriptions', - ...(!isProd() ? previewBundles : isBeta() ? previewBundles : []), -].filter(Boolean); +]; export const itLessBundles = ['openshift', 'insights', 'settings', 'iam']; @@ -92,7 +92,7 @@ type AppFilterState = { }; const useAppFilter = () => { - const isBetaEnv = isBeta(); + const isPreview = useAtomValue(isPreviewAtom); const [state, setState] = useState({ isLoaded: false, isLoading: false, @@ -193,7 +193,12 @@ const useAppFilter = () => { axios .get(`${getChromeStaticPathname('navigation')}/${fragment}-navigation.json?ts=${Date.now()}`) // fallback static CSC for EE env - .catch(() => axios.get(`${isBetaEnv ? '/beta' : ''}/config/chrome/${fragment}-navigation.json?ts=${Date.now()}`)) + .catch(() => { + // FIXME: Remove this once local preview is enabled by default + // No /beta will be needed in the future + const previewFragment = LOCAL_PREVIEW ? '' : isPreview ? '/beta' : ''; + return axios.get(`${previewFragment}/config/chrome/${fragment}-navigation.json?ts=${Date.now()}`); + }) .then(handleBundleData) .then(() => Object.values(existingSchemas).map((data) => handleBundleData({ data } as { data: BundleNavigation }))) .catch((err) => { diff --git a/src/components/ChromeRoute/ChromeRoute.tsx b/src/components/ChromeRoute/ChromeRoute.tsx index 8117eec43..8c4903a4d 100644 --- a/src/components/ChromeRoute/ChromeRoute.tsx +++ b/src/components/ChromeRoute/ChromeRoute.tsx @@ -14,6 +14,7 @@ import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { useAtomValue, useSetAtom } from 'jotai'; import { activeModuleAtom } from '../../state/atoms/activeModuleAtom'; import { gatewayErrorAtom } from '../../state/atoms/gatewayErrorAtom'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type ChromeRouteProps = { scope: string; @@ -27,6 +28,7 @@ export type ChromeRouteProps = { // eslint-disable-next-line react/display-name const ChromeRoute = memo( ({ scope, module, scopeClass, path, props }: ChromeRouteProps) => { + const isPreview = useAtomValue(isPreviewAtom); const dispatch = useDispatch(); const { setActiveHelpTopicByName } = useContext(HelpTopicContext); const { user } = useContext(ChromeAuthContext); @@ -45,7 +47,7 @@ const ChromeRoute = memo( */ if (window.pendo) { try { - window.pendo.updateOptions(getPendoConf(user as DeepRequired)); + window.pendo.updateOptions(getPendoConf(user as DeepRequired, isPreview)); } catch (error) { console.error('Unable to update pendo options'); console.error(error); diff --git a/src/components/Feedback/FeedbackModal.tsx b/src/components/Feedback/FeedbackModal.tsx index e87b043d5..4a683d77b 100644 --- a/src/components/Feedback/FeedbackModal.tsx +++ b/src/components/Feedback/FeedbackModal.tsx @@ -1,4 +1,5 @@ import React, { memo, useContext, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; import { Card, CardBody, CardTitle } from '@patternfly/react-core/dist/dynamic/components/Card'; import { FlexItem } from '@patternfly/react-core/dist/dynamic/layouts/Flex'; @@ -26,6 +27,7 @@ import { createSupportCase } from '../../utils/createCase'; import './Feedback.scss'; import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { useSegment } from '../../analytics/useSegment'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; const FEEDBACK_OPEN_EVENT = 'chrome.feedback.open'; @@ -54,6 +56,7 @@ const FeedbackModal = memo(() => { setIsModalOpen(false); setModalPage('feedbackHome'); }; + const isPreview = useAtomValue(isPreviewAtom); const ModalDescription = ({ modalPage }: { modalPage: FeedbackPages }) => { switch (modalPage) { @@ -73,7 +76,12 @@ const FeedbackModal = memo(() => { {intl.formatMessage(messages.reportABug)} {intl.formatMessage(messages.describeBugUrgentCases)} - createSupportCase(user.identity, chromeAuth.token)}> + createSupportCase(user.identity, chromeAuth.token, isPreview)} + > {intl.formatMessage(messages.openSupportCase)} diff --git a/src/components/Header/HeaderAlert.tsx b/src/components/Header/HeaderAlert.tsx index 5b01de6fd..a26e14fbe 100644 --- a/src/components/Header/HeaderAlert.tsx +++ b/src/components/Header/HeaderAlert.tsx @@ -45,6 +45,9 @@ const HeaderAlert = ({ const onClose = () => { onDismiss && onDismiss(); setAlertVisible(false); + if (timer) { + clearTimeout(timer); + } }; return ( diff --git a/src/components/Header/HeaderTests/Tools.test.js b/src/components/Header/HeaderTests/Tools.test.js index 414dc2734..051207a1c 100644 --- a/src/components/Header/HeaderTests/Tools.test.js +++ b/src/components/Header/HeaderTests/Tools.test.js @@ -7,6 +7,13 @@ import { MemoryRouter } from 'react-router-dom'; jest.mock('../UserToggle', () => () => ''); jest.mock('../ToolbarToggle', () => () => ''); +jest.mock('../../../state/atoms/releaseAtom', () => { + const util = jest.requireActual('../../../state/atoms/utils'); + return { + __esModule: true, + isPreviewAtom: util.atomWithToggle(false), + }; +}); jest.mock('@unleash/proxy-client-react', () => { const proxyClient = jest.requireActual('@unleash/proxy-client-react'); @@ -20,6 +27,13 @@ jest.mock('@unleash/proxy-client-react', () => { }); describe('Tools', () => { + let assignMock = jest.fn(); + + delete window.location; + window.location = { assign: assignMock, href: '', pathname: '' }; + afterEach(() => { + assignMock.mockClear(); + }); it('should render correctly', async () => { const mockClick = jest.fn(); let container; @@ -36,8 +50,16 @@ describe('Tools', () => { }); it('should switch release correctly', () => { - expect(switchRelease(true, '/beta/settings/rbac')).toEqual(`/settings/rbac`); - expect(switchRelease(true, '/preview/settings/rbac')).toEqual(`/settings/rbac`); - expect(switchRelease(false, '/settings/rbac')).toEqual(`/beta/settings/rbac`); + const cases = [ + ['/beta/settings/rbac', '/settings/rbac'], + ['/preview/settings/rbac', '/settings/rbac'], + ['/settings/rbac', '/settings/rbac'], + ]; + + cases.forEach(([input, expected]) => { + window.location.href = ''; + switchRelease(true, input); + expect(window.location.href).toEqual(expected); + }); }); }); diff --git a/src/components/Header/PreviewAlert.tsx b/src/components/Header/PreviewAlert.tsx new file mode 100644 index 000000000..3e86d91cb --- /dev/null +++ b/src/components/Header/PreviewAlert.tsx @@ -0,0 +1,61 @@ +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 { isBeta } from '../../utils/common'; +import { AlertActionLink, AlertVariant } from '@patternfly/react-core/dist/dynamic/components/Alert'; +import { useLocation } from 'react-router-dom'; +import { useFlag } from '@unleash/proxy-client-react'; + +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + +const PreviewAlert = ({ switchRelease }: { switchRelease: (isBeta: boolean, pathname: string, previewEnabled: boolean) => void }) => { + const [isPreview, togglePreview] = useAtom(isPreviewAtom); + const [prevPreviewValue, setPrevPreviewValue] = useState(isPreview); + const location = useLocation(); + const previewEnabled = useFlag('platform.chrome.preview'); + + // FIXME: Remove the cookie check once the local preview is enabled by default + const shouldRenderAlert = LOCAL_PREVIEW ? isPreview !== prevPreviewValue : cookie.get('cs_toggledRelease') === 'true'; + const isPreviewEnabled = LOCAL_PREVIEW ? isPreview : isBeta(); + + function handlePreviewToggle() { + if (!LOCAL_PREVIEW) { + switchRelease(isPreviewEnabled, location.pathname, previewEnabled); + } + togglePreview(); + } + + return shouldRenderAlert ? ( + + + Learn more + + { + handlePreviewToggle(); + }} + >{`${isPreviewEnabled ? '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 fbb08000d..337559361 100644 --- a/src/components/Header/SettingsToggle.tsx +++ b/src/components/Header/SettingsToggle.tsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Dropdown, DropdownGroup, DropdownItem, DropdownList } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; import { Divider } from '@patternfly/react-core/dist/dynamic/components/Divider'; import { MenuToggle } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; import { PopoverPosition } from '@patternfly/react-core/dist/dynamic/components/Popover'; import ChromeLink from '../ChromeLink/ChromeLink'; -import { isBeta } from '../../utils/common'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type SettingsToggleDropdownGroup = { title: string; @@ -35,6 +36,7 @@ export type SettingsToggleProps = { const SettingsToggle = (props: SettingsToggleProps) => { const [isOpen, setIsOpen] = useState(false); + const isPreview = useAtomValue(isPreviewAtom); const dropdownItems = props.dropdownItems.map(({ title, items }, groupIndex) => ( @@ -45,7 +47,7 @@ const SettingsToggle = (props: SettingsToggleProps) => { ouiaId={title} isDisabled={isDisabled} component={({ className: itemClassName }) => ( - + {title} )} diff --git a/src/components/Header/ToolbarToggle.tsx b/src/components/Header/ToolbarToggle.tsx index bcf4fcc1d..d13670dad 100644 --- a/src/components/Header/ToolbarToggle.tsx +++ b/src/components/Header/ToolbarToggle.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { Dropdown, DropdownItem, DropdownList } from '@patternfly/react-core/dist/dynamic/components/Dropdown'; import { MenuToggle } from '@patternfly/react-core/dist/dynamic/components/MenuToggle'; import { PopoverPosition } from '@patternfly/react-core/dist/dynamic/components/Popover'; import ChromeLink from '../ChromeLink/ChromeLink'; -import { isBeta } from '../../utils/common'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type ToolbarToggleDropdownItem = { url?: string; @@ -31,6 +32,7 @@ export type ToolbarToggleProps = { const ToolbarToggle = (props: ToolbarToggleProps) => { const [isOpen, setIsOpen] = useState(false); + const isPreview = useAtomValue(isPreviewAtom); const onSelect = () => { setIsOpen((prev) => !prev); @@ -64,7 +66,7 @@ const ToolbarToggle = (props: ToolbarToggleProps) => { component={ appId && url ? ({ className: itemClassName }) => ( - + {title} ) diff --git a/src/components/Header/Tools.tsx b/src/components/Header/Tools.tsx index 298d5b667..a567b33e8 100644 --- a/src/components/Header/Tools.tsx +++ b/src/components/Header/Tools.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { memo, useContext, useEffect, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { AlertActionLink, AlertVariant } from '@patternfly/react-core/dist/dynamic/components/Alert'; +import { useAtom, useAtomValue } 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'; @@ -15,9 +15,8 @@ import RedhatIcon from '@patternfly/react-icons/dist/dynamic/icons/redhat-icon'; import UserToggle from './UserToggle'; import ToolbarToggle, { ToolbarToggleDropdownItem } from './ToolbarToggle'; import SettingsToggle, { SettingsToggleDropdownGroup } from './SettingsToggle'; -import HeaderAlert from './HeaderAlert'; import cookie from 'js-cookie'; -import { ITLess, getRouterBasename, getSection, isBeta } from '../../utils/common'; +import { ITLess, getRouterBasename, getSection } from '../../utils/common'; import { useIntl } from 'react-intl'; import { useFlag } from '@unleash/proxy-client-react'; import messages from '../../locales/Messages'; @@ -26,22 +25,27 @@ 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 chromeStore from '../../state/chromeStore'; -import { useAtom, useAtomValue } from 'jotai'; import { notificationDrawerExpandedAtom, unreadNotificationsAtom } from '../../state/atoms/notificationDrawerAtom'; +import PreviewAlert from './PreviewAlert'; + +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; const isITLessEnv = ITLess(); +/** + * @deprecated Switch release will be replaces by the internal chrome state variable + */ export const switchRelease = (isBeta: boolean, pathname: string, previewEnabled: boolean) => { cookie.set('cs_toggledRelease', 'true'); const previewFragment = getRouterBasename(pathname); - chromeStore.set(isPreviewAtom, !isBeta); + let href = ''; if (isBeta) { - return pathname.replace(previewFragment.includes('beta') ? /\/beta/ : /\/preview/, ''); + href = pathname.replace(previewFragment.includes('beta') ? /\/beta/ : /\/preview/, ''); } else { - return previewEnabled ? `/preview${pathname}` : `/beta${pathname}`; + href = previewEnabled ? `/preview${pathname}` : `/beta${pathname}`; } + window.location.href = href; }; const InternalButton = () => ( @@ -103,6 +107,7 @@ const Tools = () => { isRhosakEntitled: false, isDemoAcc: false, }); + const [isPreview, setIsPreview] = useAtom(isPreviewAtom); const enableIntegrations = useFlag('platform.sources.integrations'); const { xs } = useWindowWidth(); const { user, token } = useContext(ChromeAuthContext); @@ -112,7 +117,7 @@ const Tools = () => { const location = useLocation(); const settingsPath = isITLessEnv ? `/settings/my-user-access` : enableIntegrations ? `/settings/integrations` : '/settings/sources'; const identityAndAccessManagmentPath = '/iam/user-access/users'; - const betaSwitcherTitle = `${isBeta() ? intl.formatMessage(messages.stopUsing) : intl.formatMessage(messages.use)} ${intl.formatMessage( + const betaSwitcherTitle = `${isPreview ? intl.formatMessage(messages.stopUsing) : intl.formatMessage(messages.use)} ${intl.formatMessage( messages.betaRelease )}`; @@ -203,7 +208,7 @@ const Tools = () => { }, { title: intl.formatMessage(messages.openSupportCase), - onClick: () => createSupportCase(user.identity, token), + onClick: () => createSupportCase(user.identity, token, isPreview), isDisabled: window.location.href.includes('/application-services') && !isRhosakEntitled, isHidden: isITLessEnv, }, @@ -239,7 +244,12 @@ const Tools = () => { }, { title: betaSwitcherTitle, - onClick: () => (window.location.href = switchRelease(isBeta(), location.pathname, previewEnabled)), + onClick: () => { + if (!LOCAL_PREVIEW) { + switchRelease(isPreview, location.pathname, previewEnabled); + } + setIsPreview(); + }, }, { title: 'separator' }, ...aboutMenuDropdownItems, @@ -268,8 +278,13 @@ const Tools = () => { label="Preview on" labelOff="Preview off" aria-label="Preview switcher" - isChecked={isBeta()} - onChange={() => (window.location.href = switchRelease(isBeta(), location.pathname, previewEnabled))} + isChecked={isPreview} + onChange={() => { + if (!LOCAL_PREVIEW) { + switchRelease(isPreview, location.pathname, previewEnabled); + } + setIsPreview(); + }} isReversed className="chr-c-beta-switcher" /> @@ -377,34 +392,7 @@ const Tools = () => { /> - {cookie.get('cs_toggledRelease') === 'true' ? ( - - - Learn more - - { - window.location.href = switchRelease(isBeta(), location.pathname, previewEnabled); - }} - > - {`${isBeta() ? 'Disable' : 'Enable'} preview`} - - - } - onDismiss={() => cookie.set('cs_toggledRelease', 'false')} - /> - ) : null} + ); }; diff --git a/src/components/Navigation/ChromeNavItem.tsx b/src/components/Navigation/ChromeNavItem.tsx index 7ee8d9f6f..4a351b176 100644 --- a/src/components/Navigation/ChromeNavItem.tsx +++ b/src/components/Navigation/ChromeNavItem.tsx @@ -9,13 +9,14 @@ import StarIcon from '@patternfly/react-icons/dist/dynamic/icons/star-icon'; import { titleCase } from 'title-case'; import classNames from 'classnames'; import get from 'lodash/get'; +import { useAtomValue } from 'jotai'; -import { isBeta } from '../../utils/common'; import ChromeLink, { LinkWrapperProps } from '../ChromeLink/ChromeLink'; import { useDispatch, useSelector } from 'react-redux'; import { markActiveProduct } from '../../redux/actions'; import { ChromeNavItemProps } from '../../@types/types'; import useFavoritePagesWrapper from '../../hooks/useFavoritePagesWrapper'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; const ChromeNavItem = ({ appId, @@ -30,6 +31,7 @@ const ChromeNavItem = ({ product, notifier = '', }: ChromeNavItemProps) => { + const isPreview = useAtomValue(isPreviewAtom); const hasNotifier = useSelector((state) => get(state, notifier)); const dispatch = useDispatch(); const { favoritePages } = useFavoritePagesWrapper(); @@ -62,7 +64,7 @@ const ChromeNavItem = ({ )} - {isBetaEnv && !isBeta() && !isExternal && ( + {isBetaEnv && !isPreview && !isExternal && ( This service is a Preview.}> diff --git a/src/components/Navigation/index.tsx b/src/components/Navigation/index.tsx index 71605853b..c84e0ddf9 100644 --- a/src/components/Navigation/index.tsx +++ b/src/components/Navigation/index.tsx @@ -1,22 +1,24 @@ import React, { Fragment, useRef, useState } from 'react'; import { Nav, NavList } from '@patternfly/react-core/dist/dynamic/components/Nav'; import { PageContextConsumer } from '@patternfly/react-core/dist/dynamic/components/Page'; +import { useAtomValue } from 'jotai'; import NavContext from './navContext'; import componentMapper from './componentMapper'; import ChromeNavItemFactory from './ChromeNavItemFactory'; import BetaInfoModal from '../../components/BetaInfoModal'; -import { isBeta } from '../../utils/common'; import NavLoader from './Loader'; import ChromeNavItem from './ChromeNavItem'; import type { Navigation as NavigationSchema } from '../../@types/types'; import { useFlag } from '@unleash/proxy-client-react'; import { getUrl } from '../../hooks/useBundle'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; export type NavigationProps = { loaded: boolean; schema: NavigationSchema }; const Navigation: React.FC = ({ loaded, schema }) => { + const isPreview = useAtomValue(isPreviewAtom); const [showBetaModal, setShowBetaModal] = useState(false); const deferedOnClickArgs = useRef<[React.MouseEvent | undefined, string | undefined, string | undefined]>([ undefined, @@ -27,7 +29,7 @@ const Navigation: React.FC = ({ loaded, schema }) => { const breadcrumbsDisabled = !useFlag('platform.chrome.bredcrumbs.enabled'); const onLinkClick = (origEvent: React.MouseEvent, href: string) => { - if (!showBetaModal && !isBeta()) { + if (!showBetaModal && !isPreview) { origEvent.preventDefault(); deferedOnClickArgs.current = [origEvent, href, origEvent?.currentTarget?.text]; setShowBetaModal(true); @@ -68,7 +70,7 @@ const Navigation: React.FC = ({ loaded, schema }) => { { - if (!isBeta()) { + if (!isPreview) { const [origEvent, href] = deferedOnClickArgs.current; const isMetaKey = event.ctrlKey || event.metaKey || origEvent?.ctrlKey || origEvent?.metaKey; const url = `${document.baseURI}beta${href}`; diff --git a/src/components/RootApp/ScalprumRoot.test.js b/src/components/RootApp/ScalprumRoot.test.js index f1adb993b..378cf7151 100644 --- a/src/components/RootApp/ScalprumRoot.test.js +++ b/src/components/RootApp/ScalprumRoot.test.js @@ -42,6 +42,14 @@ 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(() => ({ @@ -238,7 +246,7 @@ describe('ScalprumRoot', () => { }, }); - const { container } = render( + const { container } = await render( @@ -281,7 +289,7 @@ describe('ScalprumRoot', () => { }, }); - const { container } = render( + const { container } = await render( diff --git a/src/components/RootApp/ScalprumRoot.tsx b/src/components/RootApp/ScalprumRoot.tsx index e2bfd2562..1f3a2cebf 100644 --- a/src/components/RootApp/ScalprumRoot.tsx +++ b/src/components/RootApp/ScalprumRoot.tsx @@ -8,7 +8,7 @@ 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'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import chromeHistory from '../../utils/chromeHistory'; import DefaultLayout from '../../layouts/DefaultLayout'; @@ -27,7 +27,7 @@ import Footer, { FooterProps } from '../Footer/Footer'; import updateSharedScope from '../../chrome/update-shared-scope'; import useBundleVisitDetection from '../../hooks/useBundleVisitDetection'; import chromeApiWrapper from './chromeApiWrapper'; -import { ITLess, isBeta } from '../../utils/common'; +import { ITLess } from '../../utils/common'; import InternalChromeContext from '../../utils/internalChromeContext'; import useChromeServiceEvents from '../../hooks/useChromeServiceEvents'; import useTrackPendoUsage from '../../hooks/useTrackPendoUsage'; @@ -35,6 +35,7 @@ import ChromeAuthContext from '../../auth/ChromeAuthContext'; import { onRegisterModuleWriteAtom } from '../../state/atoms/chromeModuleAtom'; import useTabName from '../../hooks/useTabName'; import { NotificationData, notificationDrawerDataAtom } from '../../state/atoms/notificationDrawerAtom'; +import { isPreviewAtom } from '../../state/atoms/releaseAtom'; const ProductSelection = lazy(() => import('../Stratosphere/ProductSelection')); @@ -57,6 +58,7 @@ const ScalprumRoot = memo( const chromeAuth = useContext(ChromeAuthContext); const registerModule = useSetAtom(onRegisterModuleWriteAtom); const populateNotifications = useSetAtom(notificationDrawerDataAtom); + const isPreview = useAtomValue(isPreviewAtom); const store = useStore(); const mutableChromeApi = useRef(); @@ -152,9 +154,10 @@ const ScalprumRoot = memo( setPageMetadata, chromeAuth, registerModule, + isPreview, }); // reset chrome object after token (user) updates/changes - }, [chromeAuth.token]); + }, [chromeAuth.token, isPreview]); const scalprumProviderProps: ScalprumProviderProps<{ chrome: ChromeAPI }> = useMemo(() => { if (!mutableChromeApi.current) { @@ -183,7 +186,7 @@ const ScalprumRoot = memo( 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 ? `${isBeta() ? '/beta' : ''}/api/plugins/${manifest.name}/` : '/', + 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`, ], @@ -194,7 +197,7 @@ const ScalprumRoot = memo( }, }, }; - }, [chromeAuth.token]); + }, [chromeAuth.token, isPreview]); if (!mutableChromeApi.current) { return null; diff --git a/src/hooks/useAllLinks.ts b/src/hooks/useAllLinks.ts index a383dae0c..a10dcefea 100644 --- a/src/hooks/useAllLinks.ts +++ b/src/hooks/useAllLinks.ts @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { BundleNav, BundleNavigation, NavItem } from '../@types/types'; import fetchNavigationFiles from '../utils/fetchNavigationFiles'; import { evaluateVisibility } from '../utils/isNavItemVisible'; import { isExpandableNav } from '../utils/common'; +import { isPreviewAtom } from '../state/atoms/releaseAtom'; const getFirstChildRoute = (routes: NavItem[] = []): NavItem | undefined => { const firstLeaf = routes.find((item) => !item.expandable && item.href); @@ -81,8 +83,8 @@ const getNavLinks = (navItems: NavItem[]): NavItem[] => { return links; }; -const fetchNavigation = async () => { - const bundlesNavigation = await fetchNavigationFiles().then((data) => data.map(handleBundleResponse)); +const fetchNavigation = async (isPreview: boolean) => { + const bundlesNavigation = await fetchNavigationFiles(isPreview).then((data) => data.map(handleBundleResponse)); const parsedBundles = await Promise.all( bundlesNavigation.map(async (bundleNav) => ({ ...bundleNav, @@ -94,9 +96,10 @@ const fetchNavigation = async () => { }; const useAllLinks = () => { + const isPreview = useAtomValue(isPreviewAtom); const [allLinks, setAllLinks] = useState([]); useEffect(() => { - fetchNavigation().then(setAllLinks); + fetchNavigation(isPreview).then(setAllLinks); }, []); return allLinks; }; diff --git a/src/hooks/useFavoritedServices.ts b/src/hooks/useFavoritedServices.ts index b5f2683ff..7af6d7260 100644 --- a/src/hooks/useFavoritedServices.ts +++ b/src/hooks/useFavoritedServices.ts @@ -1,14 +1,17 @@ import { ServiceTileProps } from '../components/FavoriteServices/ServiceTile'; import useAllServices from './useAllServices'; import { useEffect, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import fetchNavigationFiles, { extractNavItemGroups } from '../utils/fetchNavigationFiles'; import { NavItem, Navigation } from '../@types/types'; import { findNavLeafPath } from '../utils/common'; import useFavoritePagesWrapper from './useFavoritePagesWrapper'; import { isAllServicesLink } from '../components/AllServices/allServicesLinks'; import useAllLinks from './useAllLinks'; +import { isPreviewAtom } from '../state/atoms/releaseAtom'; const useFavoritedServices = () => { + const isPreview = useAtomValue(isPreviewAtom); const { favoritePages } = useFavoritePagesWrapper(); const { availableSections } = useAllServices(); const allLinks = useAllLinks(); @@ -30,7 +33,7 @@ const useFavoritedServices = () => { }, [availableSections]); useEffect(() => { - fetchNavigationFiles() + fetchNavigationFiles(isPreview) .then((data) => setBundles(data as Navigation[])) .catch((error) => { console.error('Unable to fetch favorite services', error); diff --git a/src/index.ts b/src/index.ts index e1c6ff838..918dcc792 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,12 @@ Object.keys(localStorage).map((key) => { // we can't use build to set base to /beta or /preview as they both share the same build // base tag has to be adjusted once at start up function adjustBase() { + const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; const baseTag = document.getElementsByTagName('base')?.[0]; + if (LOCAL_PREVIEW) { + baseTag.href = '/'; + return; + } const previewFragment = window.location.pathname.split('/')?.[1]; if (isBeta() && baseTag && previewFragment) { baseTag.href = `/${previewFragment}/`; diff --git a/src/layouts/DefaultLayout.test.js b/src/layouts/DefaultLayout.test.js index 754b253f9..c9c1fadbf 100644 --- a/src/layouts/DefaultLayout.test.js +++ b/src/layouts/DefaultLayout.test.js @@ -5,6 +5,14 @@ import { render } from '@testing-library/react'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-redux'; +jest.mock('../state/atoms/releaseAtom', () => { + const util = jest.requireActual('../state/atoms/utils'); + return { + __esModule: true, + isPreviewAtom: util.atomWithToggle(false), + }; +}); + describe('DefaultLayout', () => { let initialState; let mockStore; diff --git a/src/state/atoms/scalprumConfigAtom.ts b/src/state/atoms/scalprumConfigAtom.ts index a15e434d2..de42acc22 100644 --- a/src/state/atoms/scalprumConfigAtom.ts +++ b/src/state/atoms/scalprumConfigAtom.ts @@ -22,20 +22,23 @@ export const writeInitialScalprumConfigAtom = atom( [key: string]: ChromeModule; } ) => { - const isBetaEnv = isBeta(); + const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + // TODO: Remove this once the local preview is enabled by default + // Assets will be loaded always from root '/' in local preview mode + const previewFragment = LOCAL_PREVIEW ? '' : isBeta() ? '/beta' : ''; const scalprumConfig = Object.entries(schema).reduce( (acc, [name, config]) => ({ ...acc, [name]: { name, module: `${name}#./RootApp`, - manifestLocation: `${window.location.origin}${isBetaEnv ? '/beta' : ''}${config.manifestLocation}?ts=${Date.now()}`, + manifestLocation: `${window.location.origin}${previewFragment}${config.manifestLocation}?ts=${Date.now()}`, }, }), { chrome: { name: 'chrome', - manifestLocation: `${window.location.origin}${isBetaEnv ? '/beta' : ''}/apps/chrome/js/fed-mods.json?ts=${Date.now()}`, + manifestLocation: `${window.location.origin}${previewFragment}/apps/chrome/js/fed-mods.json?ts=${Date.now()}`, }, } ); diff --git a/src/utils/cache.ts b/src/utils/cache.ts index cdce6f2ff..8ee4549b4 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -18,6 +18,8 @@ let store: LocalForage; * This issue may occur when the user switches between envs without logging out and in. */ const envSwap = () => { + // TODO: Remove this once the local preview is enabled by default + // Only non-beta env will exist in the future const currentEnv = isBeta() ? 'beta' : 'non-beta'; const prevEnv = localStorage.getItem('chrome:prevEnv'); if (prevEnv && currentEnv !== prevEnv) { diff --git a/src/utils/common.ts b/src/utils/common.ts index 38d91a03f..8535c037e 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -6,6 +6,8 @@ import axios from 'axios'; import { Required } from 'utility-types'; import useBundle, { getUrl } from '../hooks/useBundle'; +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + export const DEFAULT_SSO_ROUTES = { prod: { url: ['access.redhat.com', 'prod.foo.redhat.com', 'cloud.redhat.com', 'console.redhat.com', 'us.console.redhat.com'], @@ -200,11 +202,17 @@ export function isProd() { return location.host === 'cloud.redhat.com' || location.host === 'console.redhat.com' || location.host.includes('prod.foo.redhat.com'); } +/** + * @deprecated preview flag is now determined via chrome internal state variable + */ export function isBeta(pathname?: string) { const previewFragment = (pathname ?? window.location.pathname).split('/')[1]; return ['beta', 'preview'].includes(previewFragment); } +/** + * @deprecated router basename will always be `/` + */ export function getRouterBasename(pathname?: string) { const previewFragment = (pathname ?? window.location.pathname).split('/')[1]; return isBeta(pathname) ? `/${previewFragment}` : '/'; @@ -355,7 +363,10 @@ export const chromeServiceStaticPathname = { }; export function getChromeStaticPathname(type: 'modules' | 'navigation' | 'services' | 'search') { - const stableEnv = isBeta() ? 'beta' : 'stable'; + // TODO: Remove once local preview is enabled by default + // Only non-beta env will exist in the future + // Feature flags should be used to enable/disable features + const stableEnv = LOCAL_PREVIEW ? 'stable' : isBeta() ? 'beta' : 'stable'; const prodEnv = isProd() ? 'prod' : ITLess() ? 'itless' : 'stage'; return `${CHROME_SERVICE_BASE}${chromeServiceStaticPathname[stableEnv][prodEnv]}/${type}`; } diff --git a/src/utils/createCase.ts b/src/utils/createCase.ts index 5a64a460e..9006ddad0 100644 --- a/src/utils/createCase.ts +++ b/src/utils/createCase.ts @@ -3,13 +3,15 @@ import logger from '../auth/logger'; import URI from 'urijs'; const log = logger('createCase.js'); -import { getEnvDetails, isBeta, isProd } from './common'; +import { getEnvDetails, isProd } from './common'; import { HYDRA_ENDPOINT } from './consts'; import { ChromeUser } from '@redhat-cloud-services/types'; import { getUrl } from '../hooks/useBundle'; import chromeStore from '../state/chromeStore'; import { activeModuleAtom } from '../state/atoms/activeModuleAtom'; +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + // Lit of products that are bundles const BUNDLE_PRODUCTS = [ { id: 'openshift', name: 'Red Hat OpenShift Cluster Manager' }, @@ -43,8 +45,9 @@ function registerProduct() { return product?.name; } -async function getAppInfo(activeModule: string) { - let path = `${window.location.origin}${isBeta() ? '/beta/' : '/'}apps/${activeModule}/app.info.json`; +async function getAppInfo(activeModule: string, isPreview: boolean) { + const previewFragment = LOCAL_PREVIEW ? '' : isPreview ? '/beta' : ''; + let path = `${window.location.origin}${previewFragment}apps/${activeModule}/app.info.json`; try { return activeModule && (await (await fetch(path)).json()); } catch (error) { @@ -53,7 +56,7 @@ async function getAppInfo(activeModule: string) { * Transformation co camel case is requried by webpack remote moduled name requirements. * If we don't find the app info with camel case app id we try using kebab-case */ - path = `${window.location.origin}${isBeta() ? '/beta/' : '/'}apps/${activeModule.replace(/[A-Z]/g, '-$&').toLowerCase()}/app.info.json`; + path = `${window.location.origin}${previewFragment}apps/${activeModule.replace(/[A-Z]/g, '-$&').toLowerCase()}/app.info.json`; try { return activeModule && (await (await fetch(path)).json()); } catch (error) { @@ -62,21 +65,22 @@ async function getAppInfo(activeModule: string) { } } -async function getProductData() { +async function getProductData(isPreview: boolean) { const activeModule = chromeStore.get(activeModuleAtom); - const appData = await getAppInfo(activeModule ?? ''); + const appData = await getAppInfo(activeModule ?? '', isPreview); return appData; } export async function createSupportCase( userInfo: ChromeUser['identity'], token: string, + isPreview: boolean, fields?: { caseFields: Record; } ) { const currentProduct = registerProduct() || 'Other'; - const productData = await getProductData(); + const productData = await getProductData(isPreview); // a temporary fallback to getUrl() until all apps are redeployed, which will fix getProductData() - remove after some time const { src_hash, app_name } = { src_hash: productData?.src_hash, app_name: productData?.app_name ?? getUrl('app') }; const portalUrl = `${getEnvDetails()?.portal}`; @@ -98,7 +102,7 @@ export async function createSupportCase( }, sessionDetails: { createdBy: `${userInfo.user?.username}`, - environment: `Production${isBeta() ? ' Beta' : ''}, ${ + environment: `Production${isPreview ? ' Beta' : ''}, ${ src_hash ? `Current app: ${app_name}, Current app hash: ${src_hash}, Current URL: ${window.location.href}` : `Unknown app, filed on ${window.location.href}` diff --git a/src/utils/fetchNavigationFiles.ts b/src/utils/fetchNavigationFiles.ts index 30067e936..58274add5 100644 --- a/src/utils/fetchNavigationFiles.ts +++ b/src/utils/fetchNavigationFiles.ts @@ -2,14 +2,14 @@ import axios from 'axios'; import { BundleNavigation, NavItem, Navigation } from '../@types/types'; import { Required } from 'utility-types'; import { itLessBundles, requiredBundles } from '../components/AppFilter/useAppFilter'; -import { ITLess, getChromeStaticPathname, isBeta } from './common'; +import { ITLess, getChromeStaticPathname } from './common'; + +const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; export function isBundleNavigation(item: unknown): item is BundleNavigation { return typeof item !== 'undefined'; } -const bundles = ITLess() ? itLessBundles : requiredBundles; - export function isNavItems(navigation: Navigation | NavItem[]): navigation is Navigation { return Array.isArray((navigation as Navigation).navItems); } @@ -39,7 +39,8 @@ const filesCache: { existingRequest: undefined, }; -const fetchNavigationFiles = async () => { +const fetchNavigationFiles = async (isPreview: boolean) => { + const bundles = ITLess() ? itLessBundles : requiredBundles; if (filesCache.ready && filesCache.expires > Date.now()) { return filesCache.data; } @@ -53,7 +54,12 @@ const fetchNavigationFiles = async () => { bundles.map((fragment) => axios .get(`${getChromeStaticPathname('navigation')}/${fragment}-navigation.json?ts=${Date.now()}`) - .catch(() => axios.get(`${isBeta() ? '/beta' : ''}/config/chrome/${fragment}-navigation.json?ts=${Date.now()}`)) + .catch(() => { + // FIXME: Remove this once local preview is enabled by default + // No /beta will be needed in the future + const previewFragment = LOCAL_PREVIEW ? '' : isPreview ? '/beta' : ''; + return axios.get(`${previewFragment}/config/chrome/${fragment}-navigation.json?ts=${Date.now()}`); + }) .then((response) => response.data) .catch((err) => { console.error('Unable to load bundle navigation', err, fragment); diff --git a/src/utils/useNavigation.ts b/src/utils/useNavigation.ts index 0c312e300..d11ad4e82 100644 --- a/src/utils/useNavigation.ts +++ b/src/utils/useNavigation.ts @@ -1,16 +1,17 @@ import axios from 'axios'; -import { useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useContext, useEffect, useRef, useState } from 'react'; import { batch, useDispatch, useSelector } from 'react-redux'; import { loadLeftNavSegment } from '../redux/actions'; import { useLocation, useNavigate } from 'react-router-dom'; -import { BLOCK_CLEAR_GATEWAY_ERROR, getChromeStaticPathname, isBeta } from './common'; +import { BLOCK_CLEAR_GATEWAY_ERROR, getChromeStaticPathname } from './common'; import { evaluateVisibility } from './isNavItemVisible'; import { QuickStartContext } from '@patternfly/quickstarts'; import { useFlagsStatus } from '@unleash/proxy-client-react'; import { BundleNavigation, NavItem, Navigation } from '../@types/types'; import { ReduxState } from '../redux/store'; import { clearGatewayErrorAtom } from '../state/atoms/gatewayErrorAtom'; +import { isPreviewAtom } from '../state/atoms/releaseAtom'; function cleanNavItemsHref(navItem: NavItem) { const result = { ...navItem }; @@ -55,6 +56,7 @@ const useNavigation = () => { const currentNamespace = pathname.split('/')[1]; const schema = useSelector(({ chrome: { navigation } }: ReduxState) => navigation[currentNamespace] as Navigation); const [noNav, setNoNav] = useState(false); + const isPreview = useAtomValue(isPreviewAtom); /** * We need a side effect to get the value into the mutation observer closure @@ -111,7 +113,11 @@ const useNavigation = () => { axios .get(`${getChromeStaticPathname('navigation')}/${currentNamespace}-navigation.json`) // fallback static CSC for EE env - .catch(() => axios.get(`${isBeta() ? '/beta' : ''}/config/chrome/${currentNamespace}-navigation.json?ts=${Date.now()}`)) + .catch(() => { + const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; + const previewFragment = LOCAL_PREVIEW ? '' : isPreview ? '/beta' : ''; + return axios.get(`${previewFragment}/config/chrome/${currentNamespace}-navigation.json?ts=${Date.now()}`); + }) .then(async (response) => { if (observer && typeof observer.disconnect === 'function') { observer.disconnect(); diff --git a/src/utils/usePreviewFlag.ts b/src/utils/usePreviewFlag.ts deleted file mode 100644 index e28a3a958..000000000 --- a/src/utils/usePreviewFlag.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useFlag } from '@unleash/proxy-client-react'; -import { isBeta, isProd } from './common'; - -export const usePreviewFlag = (flag: string) => { - const notificationsOverhaul = useFlag(flag); - - if (isProd() && !isBeta()) { - return false; - } - - return notificationsOverhaul; -}; From 2913f39035c87658513607b36b4be80facf3c3bd Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Tue, 14 May 2024 14:43:27 +0200 Subject: [PATCH 4/7] Force reload Scalprum route component on preview change this will ensure that any access to isBeta chrome API in runtime is triggered. The routes children should be re-initialized. --- src/components/ChromeRoute/ChromeRoute.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChromeRoute/ChromeRoute.tsx b/src/components/ChromeRoute/ChromeRoute.tsx index 8c4903a4d..f8e7cf7b4 100644 --- a/src/components/ChromeRoute/ChromeRoute.tsx +++ b/src/components/ChromeRoute/ChromeRoute.tsx @@ -75,7 +75,7 @@ const ChromeRoute = memo(
} fallback={LoadingFallback} // LoadingFallback={() => LoadingFallback} From 632bd2df28e2886ae3a2de752b9608b3e10ed694 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Wed, 15 May 2024 09:19:51 +0200 Subject: [PATCH 5/7] Fix unleash circular dependency issues. --- .../FeatureFlags/FeatureFlagsProvider.tsx | 59 ++++++++----------- src/components/FeatureFlags/unleashClient.ts | 27 +++++++++ src/state/atoms/releaseAtom.ts | 12 ++-- src/utils/VisibilitySingleton.ts | 4 +- 4 files changed, 61 insertions(+), 41 deletions(-) create mode 100644 src/components/FeatureFlags/unleashClient.ts diff --git a/src/components/FeatureFlags/FeatureFlagsProvider.tsx b/src/components/FeatureFlags/FeatureFlagsProvider.tsx index 383710144..f1913f21d 100644 --- a/src/components/FeatureFlags/FeatureFlagsProvider.tsx +++ b/src/components/FeatureFlags/FeatureFlagsProvider.tsx @@ -6,6 +6,7 @@ import * as Sentry from '@sentry/react'; import { useAtomValue } from 'jotai'; import ChromeAuthContext, { ChromeAuthContextValue } from '../../auth/ChromeAuthContext'; import { isPreviewAtom } from '../../state/atoms/releaseAtom'; +import { UNLEASH_ERROR_KEY, getUnleashClient, setUnleashClient } from './unleashClient'; const config: IFlagProvider['config'] = { url: `${document.location.origin}/api/featureflags/v0`, @@ -52,43 +53,33 @@ const config: IFlagProvider['config'] = { }, }; -export const UNLEASH_ERROR_KEY = 'chrome:feature-flags:error'; - -/** - * Clear error localstorage flag before initialization - */ -localStorage.setItem(UNLEASH_ERROR_KEY, 'false'); - -export let unleashClient: UnleashClient; -export const getFeatureFlagsError = () => localStorage.getItem(UNLEASH_ERROR_KEY) === 'true'; - const FeatureFlagsProvider: React.FC = ({ children }) => { const { user } = useContext(ChromeAuthContext) as DeepRequired; const isPreview = useAtomValue(isPreviewAtom); - unleashClient = useMemo( - () => - new UnleashClient({ - ...config, - context: { - // the unleash context is not generic, look for issue/PR in the unleash repo or create one - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - 'platform.chrome.ui.preview': isPreview, - userId: user?.identity.internal?.account_id, - orgId: user?.identity.internal?.org_id, - ...(user - ? { - properties: { - account_number: user?.identity.account_number, - email: user?.identity.user.email, - }, - } - : {}), - }, - }), - [] - ); - return {children}; + useMemo(() => { + const client = new UnleashClient({ + ...config, + context: { + // the unleash context is not generic, look for issue/PR in the unleash repo or create one + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 'platform.chrome.ui.preview': isPreview, + userId: user?.identity.internal?.account_id, + orgId: user?.identity.internal?.org_id, + ...(user + ? { + properties: { + account_number: user?.identity.account_number, + email: user?.identity.user.email, + }, + } + : {}), + }, + }); + setUnleashClient(client); + return client; + }, []); + return {children}; }; export default FeatureFlagsProvider; diff --git a/src/components/FeatureFlags/unleashClient.ts b/src/components/FeatureFlags/unleashClient.ts new file mode 100644 index 000000000..e8b347599 --- /dev/null +++ b/src/components/FeatureFlags/unleashClient.ts @@ -0,0 +1,27 @@ +import { UnleashClient } from '@unleash/proxy-client-react'; + +let unleashClient: UnleashClient; + +export const UNLEASH_ERROR_KEY = 'chrome:feature-flags:error'; + +/** + * Clear error localstorage flag before initialization + */ +localStorage.setItem(UNLEASH_ERROR_KEY, 'false'); + +export const getFeatureFlagsError = () => localStorage.getItem(UNLEASH_ERROR_KEY) === 'true'; + +export function getUnleashClient() { + if (!unleashClient) { + throw new Error('UnleashClient not initialized!'); + } + return unleashClient; +} + +export function setUnleashClient(client: UnleashClient) { + unleashClient = client; +} + +export function unleashClientExists() { + return !!unleashClient; +} diff --git a/src/state/atoms/releaseAtom.ts b/src/state/atoms/releaseAtom.ts index 957e62339..31faf80dc 100644 --- a/src/state/atoms/releaseAtom.ts +++ b/src/state/atoms/releaseAtom.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { updateVisibilityFunctionsBeta, visibilityFunctionsExist } from '../../utils/VisibilitySingleton'; import { atomWithToggle } from './utils'; -import { unleashClient } from '../../components/FeatureFlags/FeatureFlagsProvider'; +import { getUnleashClient, unleashClientExists } from '../../components/FeatureFlags/unleashClient'; export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { // Required to change the `isBeta` function return value in the visibility functions @@ -9,8 +9,10 @@ export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { updateVisibilityFunctionsBeta(isPreview); axios.post('/api/chrome-service/v1/user/update-ui-preview', { uiPreview: isPreview }); } - // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - unleashClient?.updateContext({ 'platform.chrome.ui.preview': isPreview }); + if (unleashClientExists()) { + // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getUnleashClient().updateContext({ 'platform.chrome.ui.preview': isPreview }); + } }); diff --git a/src/utils/VisibilitySingleton.ts b/src/utils/VisibilitySingleton.ts index 5362b084c..acfb1a308 100644 --- a/src/utils/VisibilitySingleton.ts +++ b/src/utils/VisibilitySingleton.ts @@ -4,8 +4,8 @@ import cookie from 'js-cookie'; import axios, { AxiosRequestConfig } from 'axios'; import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; -import { getFeatureFlagsError, unleashClient } from '../components/FeatureFlags/FeatureFlagsProvider'; import { getSharedScope, initSharedScope } from '@scalprum/core'; +import { getFeatureFlagsError, getUnleashClient } from '../components/FeatureFlags/unleashClient'; const matcherMapper = { isEmpty, @@ -132,7 +132,7 @@ const initialize = ({ } }, featureFlag: (flagName: string, expectedValue: boolean) => - getFeatureFlagsError() !== true && unleashClient?.isEnabled(flagName) === expectedValue, + getFeatureFlagsError() !== true && getUnleashClient()?.isEnabled(flagName) === expectedValue, }; // in order to properly distribute the module, it has be added to the webpack share scope to avoid reference issues if these functions are called from chrome shared modules From 34dfe7ac6fc05244d6db8de523fe2ac2f3cfc17c Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Mon, 20 May 2024 11:49:20 +0200 Subject: [PATCH 6/7] Fix missing context in cypress component tests. --- .../AllServicesPage/AllServicesPage.cy.tsx | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/cypress/component/AllServicesPage/AllServicesPage.cy.tsx b/cypress/component/AllServicesPage/AllServicesPage.cy.tsx index 3f27c4a56..3fc8141e6 100644 --- a/cypress/component/AllServicesPage/AllServicesPage.cy.tsx +++ b/cypress/component/AllServicesPage/AllServicesPage.cy.tsx @@ -8,6 +8,8 @@ import { ScalprumProvider } from '@scalprum/react-core'; import { getVisibilityFunctions, initializeVisibilityFunctions } from '../../../src/utils/VisibilitySingleton'; import userFixture from '../../fixtures/testUser.json'; import { ChromeUser } from '@redhat-cloud-services/types'; +import { FeatureFlagsProvider } from '../../../src/components/FeatureFlags'; +import ChromeAuthContext from '../../../src/auth/ChromeAuthContext'; describe('', () => { beforeEach(() => { @@ -31,6 +33,7 @@ describe('', () => { it('should filter by service category title', () => { initializeVisibilityFunctions({ + isPreview: false, getToken: () => Promise.resolve(''), getUser: () => Promise.resolve(userFixture as unknown as ChromeUser), getUserPermissions: () => Promise.resolve([]), @@ -48,22 +51,28 @@ describe('', () => { }, })); cy.mount( - - - - - - - - - + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + + + + + + + + + + + + + ); cy.get('.pf-v5-c-text-input-group__text-input').type('consoleset'); From d4ed4b7475edc0dbab5745967efd1b16e06f70e4 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Wed, 22 May 2024 08:59:25 +0200 Subject: [PATCH 7/7] Adress PR feedback. --- cypress.config.ts | 2 +- cypress/e2e/auth/OIDC/OIDCState.cy.ts | 3 ++- src/components/AppFilter/useAppFilter.ts | 12 +---------- src/components/Feedback/FeedbackModal.tsx | 3 +-- src/components/Header/Tools.tsx | 23 +++++++++----------- src/state/atoms/releaseAtom.ts | 26 +++++++++++++---------- src/utils/initUserConfig.ts | 1 + 7 files changed, 31 insertions(+), 39 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index dce3ed31d..f90523287 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -34,7 +34,7 @@ export default defineConfig({ }, e2e: { blockHosts: ['consent.trustarc.com'], - baseUrl: 'https://stage.foo.redhat.com:1337/beta', + baseUrl: 'https://stage.foo.redhat.com:1337/', env: { E2E_USER: process.env.E2E_USER, E2E_PASSWORD: process.env.E2E_PASSWORD, diff --git a/cypress/e2e/auth/OIDC/OIDCState.cy.ts b/cypress/e2e/auth/OIDC/OIDCState.cy.ts index 680a4df00..e37192d2b 100644 --- a/cypress/e2e/auth/OIDC/OIDCState.cy.ts +++ b/cypress/e2e/auth/OIDC/OIDCState.cy.ts @@ -26,10 +26,11 @@ describe('OIDC State', () => { // Enable cypress exceptions again return true; }); + cy.wait(1000); // The reloader should preserve pathname and query params const url = new URL(win.location.href); expect(url.hash).to.be.empty; - expect(url.pathname).to.eq('/beta/foo/bar'); + expect(url.pathname).to.eq('/foo/bar'); expect(url.search).to.eq('?baz=quaz'); cy.contains('Insights QA').should('exist'); }); diff --git a/src/components/AppFilter/useAppFilter.ts b/src/components/AppFilter/useAppFilter.ts index ae894ed76..8bf9846ad 100644 --- a/src/components/AppFilter/useAppFilter.ts +++ b/src/components/AppFilter/useAppFilter.ts @@ -17,17 +17,7 @@ export type AppFilterBucket = { links: NavItem[]; }; -export const requiredBundles = [ - 'application-services', - 'openshift', - 'insights', - 'edge', - 'ansible', - 'settings', - 'iam', - 'quay', - 'subscriptions', -]; +export const requiredBundles = ['application-services', 'openshift', 'insights', 'edge', 'ansible', 'settings', 'iam', 'quay', 'subscriptions']; export const itLessBundles = ['openshift', 'insights', 'settings', 'iam']; diff --git a/src/components/Feedback/FeedbackModal.tsx b/src/components/Feedback/FeedbackModal.tsx index 4a683d77b..943c9276a 100644 --- a/src/components/Feedback/FeedbackModal.tsx +++ b/src/components/Feedback/FeedbackModal.tsx @@ -1,5 +1,5 @@ import React, { memo, useContext, useState } from 'react'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { Button } from '@patternfly/react-core/dist/dynamic/components/Button'; import { Card, CardBody, CardTitle } from '@patternfly/react-core/dist/dynamic/components/Card'; import { FlexItem } from '@patternfly/react-core/dist/dynamic/layouts/Flex'; @@ -13,7 +13,6 @@ import OutlinedCommentsIcon from '@patternfly/react-icons/dist/dynamic/icons/out import { DeepRequired } from 'utility-types'; import { ChromeUser } from '@redhat-cloud-services/types'; import { useIntl } from 'react-intl'; -import { useAtom, useAtomValue } from 'jotai'; import { isFeedbackModalOpenAtom, usePendoFeedbackAtom } from '../../state/atoms/feedbackModalAtom'; import feedbackIllo from '../../../static/images/feedback_illo.svg'; diff --git a/src/components/Header/Tools.tsx b/src/components/Header/Tools.tsx index a567b33e8..029f45bc8 100644 --- a/src/components/Header/Tools.tsx +++ b/src/components/Header/Tools.tsx @@ -33,7 +33,7 @@ const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; const isITLessEnv = ITLess(); /** - * @deprecated Switch release will be replaces by the internal chrome state variable + * @deprecated Switch release will be replaced by the internal chrome state variable */ export const switchRelease = (isBeta: boolean, pathname: string, previewEnabled: boolean) => { cookie.set('cs_toggledRelease', 'true'); @@ -185,6 +185,13 @@ const Tools = () => { : []), ]; + const handleToggle = () => { + if (!LOCAL_PREVIEW) { + switchRelease(isPreview, location.pathname, previewEnabled); + } + setIsPreview(); + }; + useEffect(() => { if (user) { setState({ @@ -244,12 +251,7 @@ const Tools = () => { }, { title: betaSwitcherTitle, - onClick: () => { - if (!LOCAL_PREVIEW) { - switchRelease(isPreview, location.pathname, previewEnabled); - } - setIsPreview(); - }, + onClick: handleToggle, }, { title: 'separator' }, ...aboutMenuDropdownItems, @@ -279,12 +281,7 @@ const Tools = () => { labelOff="Preview off" aria-label="Preview switcher" isChecked={isPreview} - onChange={() => { - if (!LOCAL_PREVIEW) { - switchRelease(isPreview, location.pathname, previewEnabled); - } - setIsPreview(); - }} + onChange={handleToggle} isReversed className="chr-c-beta-switcher" /> diff --git a/src/state/atoms/releaseAtom.ts b/src/state/atoms/releaseAtom.ts index 31faf80dc..dc8fae6ac 100644 --- a/src/state/atoms/releaseAtom.ts +++ b/src/state/atoms/releaseAtom.ts @@ -3,16 +3,20 @@ import { updateVisibilityFunctionsBeta, visibilityFunctionsExist } from '../../u import { atomWithToggle } from './utils'; import { getUnleashClient, unleashClientExists } from '../../components/FeatureFlags/unleashClient'; -export const isPreviewAtom = atomWithToggle(undefined, (isPreview) => { - // Required to change the `isBeta` function return value in the visibility functions - if (visibilityFunctionsExist()) { - updateVisibilityFunctionsBeta(isPreview); - axios.post('/api/chrome-service/v1/user/update-ui-preview', { uiPreview: isPreview }); - } - if (unleashClientExists()) { - // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - getUnleashClient().updateContext({ 'platform.chrome.ui.preview': isPreview }); +export const isPreviewAtom = atomWithToggle(undefined, async (isPreview) => { + try { + // Required to change the `isBeta` function return value in the visibility functions + if (visibilityFunctionsExist()) { + updateVisibilityFunctionsBeta(isPreview); + await axios.post('/api/chrome-service/v1/user/update-ui-preview', { uiPreview: isPreview }); + } + if (unleashClientExists()) { + // Required to change the `platform.chrome.ui.preview` context in the feature flags, TS is bugged + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getUnleashClient().updateContext({ 'platform.chrome.ui.preview': isPreview }); + } + } catch (error) { + console.error('Failed to update the visibility functions or feature flags context', error); } }); diff --git a/src/utils/initUserConfig.ts b/src/utils/initUserConfig.ts index e9db70611..3a2c81ae7 100644 --- a/src/utils/initUserConfig.ts +++ b/src/utils/initUserConfig.ts @@ -13,6 +13,7 @@ export type ChromeUserConfig = { export const initChromeUserConfig = async ({ getUser, token }: { getUser: () => Promise; token: string }) => { const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; let config: ChromeUserConfig; + // FIXME: remove this once fully switched to internal preview if (!LOCAL_PREVIEW) { config = { data: {