From 50a1296219a3ee46aeaab639c14766cdf7c6e567 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 6 Feb 2023 23:02:30 -0500 Subject: [PATCH] refactor(router): sw-625 move to router v6 * build, routing package, module ref, spelling * configs, sorted products, aliases, simplify routes * locale, OpenShift strings * AppEntry, remove router component * app, testing unnecessary reloads * helpers, add memoize, object freeze * authenticationContext, remove nav history push * i18n, testing unnecessary reloads * productView, useRouteDetail hook update * productViewMissing, add useNavigate, RouteDetail hooks * redux, viewReducer, appTypes for storing route ref * router, simplify, useSetRouteDetail for config load * routerContext, useRouteDetail restructure * routerContext, useLocation, useNavigate wrappers * routerHelpers, restructure getRouteConfigByPath, memo update --- config/build.plugins.js | 5 +- config/cspell.config.json | 1 + package.json | 5 +- public/locales/en-US.json | 6 + src/AppEntry.js | 15 +- src/app.js | 13 +- .../__snapshots__/helpers.test.js.snap | 35 + src/common/__tests__/helpers.test.js | 67 + src/common/helpers.js | 61 + .../authenticationContext.test.js.snap | 3 - .../authentication/authenticationContext.js | 17 +- .../__tests__/__snapshots__/i18n.test.js.snap | 27 +- src/components/i18n/i18n.js | 47 +- .../__snapshots__/productView.test.js.snap | 276 +- .../productViewContext.test.js.snap | 6 + .../productViewMissing.test.js.snap | 385 +-- .../productView/__tests__/productView.test.js | 96 +- .../__tests__/productViewMissing.test.js | 21 +- src/components/productView/productView.js | 113 +- .../productView/productViewMissing.js | 64 +- .../__snapshots__/router.test.js.snap | 2463 +---------------- .../__snapshots__/routerContext.test.js.snap | 165 +- .../__snapshots__/routerHelpers.test.js.snap | 28 +- .../router/__tests__/router.test.js | 39 +- .../router/__tests__/routerContext.test.js | 98 +- .../router/__tests__/routerHelpers.test.js | 15 +- src/components/router/router.js | 98 +- src/components/router/routerContext.js | 244 +- src/components/router/routerHelpers.js | 100 +- .../__snapshots__/index.test.js.snap | 18 +- .../__snapshots__/products.test.js.snap | 63 + .../__snapshots__/routes.test.js.snap | 16 +- src/config/__tests__/index.test.js | 5 +- src/config/product.openshiftContainer.js | 2 +- src/config/product.openshiftDedicated.js | 2 +- src/config/product.openshiftMetrics.js | 2 +- src/config/product.rhacs.js | 2 +- src/config/product.rhel.js | 2 +- src/config/product.rhods.js | 2 +- src/config/product.rhosak.js | 2 +- src/config/product.satellite.js | 2 +- src/config/products.js | 124 +- src/config/routes.js | 32 +- src/index.appEntry.js | 18 + src/index.bootstrap.js | 7 + src/index.js | 2 +- .../__snapshots__/viewReducer.test.js.snap | 32 + .../reducers/__tests__/viewReducer.test.js | 6 +- src/redux/reducers/viewReducer.js | 14 +- .../__snapshots__/index.test.js.snap | 4 + src/redux/types/appTypes.js | 6 +- tests/__snapshots__/code.test.js.snap | 15 + tests/__snapshots__/dist.test.js.snap | 219 +- tests/dist.test.js | 24 +- yarn.lock | 124 +- 55 files changed, 1852 insertions(+), 3406 deletions(-) create mode 100644 src/index.appEntry.js create mode 100644 src/index.bootstrap.js diff --git a/config/build.plugins.js b/config/build.plugins.js index c19413022..263eff5a6 100644 --- a/config/build.plugins.js +++ b/config/build.plugins.js @@ -33,7 +33,10 @@ const setCommonPlugins = () => { }), fedModulePlugin({ root: RELATIVE_DIRNAME, - shared: [{ 'react-redux': { requiredVersion: dependencies['react-redux'] } }] + shared: [ + { 'react-router-dom': { singleton: true, requiredVersion: '*' } }, + { 'react-redux': { requiredVersion: dependencies['react-redux'] } } + ] }) ]; diff --git a/config/cspell.config.json b/config/cspell.config.json index 47e36bb34..391e530cd 100644 --- a/config/cspell.config.json +++ b/config/cspell.config.json @@ -25,6 +25,7 @@ "ibmz", "iife", "ipsum", + "ized", "keycloak", "kubernetes", "labelledby", diff --git a/package.json b/package.json index dd24f723d..4fb79a9fb 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "axios": "^0.27.2", "classnames": "^2.3.2", "crypto-js": "^4.1.1", + "fastest-levenshtein": "^1.0.16", "i18next": "^22.0.6", "i18next-http-backend": "^2.0.2", "iso-639-1": "^2.1.15", @@ -97,8 +98,8 @@ "react-dom": "^17.0.2", "react-i18next": "^12.0.0", "react-redux": "^8.0.5", - "react-router": "5.3.3", - "react-router-dom": "5.3.0", + "react-router": "6.8.1", + "react-router-dom": "6.8.1", "react-use": "^17.4.0", "redux": "^4.2.0", "redux-logger": "^3.0.6", diff --git a/public/locales/en-US.json b/public/locales/en-US.json index 439f5de86..292728737 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -369,6 +369,12 @@ "title_OpenShift Container Platform": "OpenShift Container Platform", "subtitle_OpenShift Container Platform": "Monitor your OpenShift Container Platform usage for both Annual and On-Demand subscriptions. <0>Learn more about {{appName}} reporting", "description_OpenShift Container Platform": "Monitor your OpenShift Container Platform usage for both Annual and On-Demand subscriptions.", + "title_openshift-container": "$t(curiosity-view.title_OpenShift Container Platform)", + "subtitle_openshift-container": "$t(curiosity-view.subtitle_OpenShift Container Platform)", + "description_openshift-container": "$t(curiosity-view.description_OpenShift Container Platform)", + "title_OpenShift-metrics": "OpenShift Container Platform On-Demand", + "subtitle_OpenShift-metrics": "Monitor your OpenShift Container Platform usage for On-Demand subscriptions.", + "description_OpenShift-metrics": "Monitor your OpenShift Container Platform usage for On-Demand subscriptions.", "title_OpenShift-dedicated-metrics": "OpenShift Dedicated", "subtitle_OpenShift-dedicated-metrics": "Monitor your OpenShift Dedicated usage for On-Demand subscriptions. <0>Learn more about {{appName}} reporting", "description_OpenShift-dedicated-metrics": "Monitor your OpenShift Dedicated usage for On-Demand subscriptions.", diff --git a/src/AppEntry.js b/src/AppEntry.js index 5263c7141..aeff9c274 100644 --- a/src/AppEntry.js +++ b/src/AppEntry.js @@ -1,18 +1,17 @@ import React from 'react'; import { Provider } from 'react-redux'; -import { BrowserRouter } from 'react-router-dom'; -import { routerHelpers } from './components/router'; import { store } from './redux'; import App from './app'; import './styles/index.scss'; import '@patternfly/react-styles/css/components/Select/select.css'; -const AppEntry = () => ( - - +const AppEntry = () => { + console.log('>>> APP ENTRY LOAD >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>'); + return ( + - - -); + + ); +}; export { AppEntry as default, AppEntry }; diff --git a/src/app.js b/src/app.js index 96764bac2..9ddd37786 100644 --- a/src/app.js +++ b/src/app.js @@ -25,9 +25,12 @@ const App = ({ getLocale, useDispatch: useAliasDispatch, useSelector: useAliasSe const dispatch = useAliasDispatch(); const { value: locale } = useAliasSelector(({ user }) => user?.locale?.data, {}); let platformNotifications = null; - + console.log('>>> APP load', locale); useMount(() => { - dispatch(getLocale()); + console.log('>>> APP mount comp', locale); + if (!locale) { + dispatch(getLocale()); + } }); if (!helpers.UI_DISABLED_NOTIFICATIONS) { @@ -35,10 +38,10 @@ const App = ({ getLocale, useDispatch: useAliasDispatch, useSelector: useAliasSe } return ( - + {platformNotifications} - - + + ); diff --git a/src/common/__tests__/__snapshots__/helpers.test.js.snap b/src/common/__tests__/__snapshots__/helpers.test.js.snap index 8c818de76..6828bd7c9 100644 --- a/src/common/__tests__/__snapshots__/helpers.test.js.snap +++ b/src/common/__tests__/__snapshots__/helpers.test.js.snap @@ -42,10 +42,12 @@ exports[`Helpers should expose a window object: limited window object 1`] = ` "isDate": [Function], "isPromise": [Function], "lorem": "ipsum", + "memo": [Function], "noop": [Function], "noopPromise": Promise {}, "noopTranslate": [Function], "numberDisplay": [Function], + "objFreeze": [Function], } `; @@ -85,10 +87,12 @@ exports[`Helpers should expose a window object: window object 1`] = ` "isDate": [Function], "isPromise": [Function], "lorem": "ipsum", + "memo": [Function], "noop": [Function], "noopPromise": Promise {}, "noopTranslate": [Function], "numberDisplay": [Function], + "objFreeze": [Function], } `; @@ -139,13 +143,44 @@ exports[`Helpers should have specific functions: helpers 1`] = ` "generateId": [Function], "isDate": [Function], "isPromise": [Function], + "memo": [Function], "noop": [Function], "noopPromise": Promise {}, "noopTranslate": [Function], "numberDisplay": [Function], + "objFreeze": [Function], } `; +exports[`Helpers should produce an immutable like object: clone 1`] = ` +{ + "dolor": { + "hello": [ + "world", + "clone", + ], + "sit": { + "lorem": "ipsum", + }, + }, + "lorem": "ipsum", +} +`; + +exports[`Helpers should produce an immutable like object: delete property 1`] = `"Cannot delete property 'sit' of #"`; + +exports[`Helpers should produce an immutable like object: set nested property 1`] = `"Cannot assign to read only property 'lorem' of object '#'"`; + +exports[`Helpers should produce an immutable like object: set property 1`] = `"Cannot assign to read only property 'lorem' of object '#'"`; + +exports[`Helpers should produce an immutable like object: shallow clone 1`] = `"Cannot add property 1, object is not extensible"`; + +exports[`Helpers should produce an immutable like object: update nested property list 1`] = `"Cannot add property 1, object is not extensible"`; + +exports[`Helpers should produce an immutable like object: update nested property list length 1`] = `"Cannot assign to read only property 'length' of object '[object Array]'"`; + +exports[`Helpers should produce an immutable like object: update nested property list values 1`] = `"Cannot delete property '0' of [object Array]"`; + exports[`Helpers should support generated consistent hashes from objects, primitive values: hash, object and primitive values 1`] = ` { "valueArray": "41d49497cb062fec8dd0a1e9298650aeb2364f35", diff --git a/src/common/__tests__/helpers.test.js b/src/common/__tests__/helpers.test.js index 995869599..4e2581c86 100644 --- a/src/common/__tests__/helpers.test.js +++ b/src/common/__tests__/helpers.test.js @@ -1,4 +1,5 @@ import cryptoMd5 from 'crypto-js/md5'; +import _cloneDeep from 'lodash/cloneDeep'; import { helpers } from '../helpers'; describe('Helpers', () => { @@ -96,6 +97,34 @@ describe('Helpers', () => { expect(helpers.isPromise(() => 'lorem')).toBe(false); }); + it('should memoize function return values with memo', () => { + const testArr = []; + const testMemoReturnValue = helpers.memo( + str => { + const arr = ['lorem', 'ipsum', 'dolor', 'sit']; + const randomStr = Math.floor(Math.random() * arr.length); + const genStr = `${arr[randomStr]}-${str}`; + testArr.push(genStr); + return genStr; + }, + { cacheLimit: 3 } + ); + + testMemoReturnValue('one'); + testMemoReturnValue('one'); + testMemoReturnValue('one'); + testMemoReturnValue('two'); + testMemoReturnValue('three'); + + expect(testArr[0] === testMemoReturnValue('one')).toBe(true); + expect(testArr[1] === testMemoReturnValue('two')).toBe(true); + expect(testArr[2] === testMemoReturnValue('three')).toBe(true); + expect(testArr[2] === testMemoReturnValue('three')).toBe(true); + + testMemoReturnValue('four'); + expect(testArr[3] === testMemoReturnValue('four')).toBe(true); + }); + it('should apply a number display function', () => { expect(helpers.numberDisplay(null)).toBe(null); expect(helpers.numberDisplay(undefined)).toBe(undefined); @@ -103,6 +132,44 @@ describe('Helpers', () => { expect(helpers.numberDisplay(11)).toMatchSnapshot('number display function result'); }); + it('should produce an immutable like object', () => { + const mockObj = {}; + + mockObj.lorem = 'ipsum'; + mockObj.dolor = { + sit: { + lorem: 'ipsum' + }, + hello: ['world'] + }; + + helpers.objFreeze(mockObj); + + expect(() => delete mockObj.dolor.sit).toThrowErrorMatchingSnapshot('delete property'); + expect(() => { + mockObj.lorem = 'hello world'; + }).toThrowErrorMatchingSnapshot('set property'); + expect(() => { + mockObj.dolor.sit.lorem = 'hello world'; + }).toThrowErrorMatchingSnapshot('set nested property'); + expect(() => { + mockObj.dolor.hello.push('hello'); + }).toThrowErrorMatchingSnapshot('update nested property list'); + expect(() => { + mockObj.dolor.hello.pop(); + }).toThrowErrorMatchingSnapshot('update nested property list values'); + expect(() => { + mockObj.dolor.hello.length = 0; + }).toThrowErrorMatchingSnapshot('update nested property list length'); + expect(() => { + ({ ...mockObj }).dolor.hello.push('shallow clone'); + }).toThrowErrorMatchingSnapshot('shallow clone'); + + const clone = _cloneDeep(mockObj); + clone.dolor.hello.push('clone'); + expect(clone).toMatchSnapshot('clone'); + }); + it('should expose a window object', () => { helpers.browserExpose({ lorem: 'ipsum' }); expect(window[helpers.UI_WINDOW_ID]).toMatchSnapshot('window object'); diff --git a/src/common/helpers.js b/src/common/helpers.js index 2e00ae2db..613dd9408 100644 --- a/src/common/helpers.js +++ b/src/common/helpers.js @@ -80,6 +80,36 @@ const generateHash = (anyValue, { method = cryptoSha1 } = {}) => }) ).toString(); +/** + * Simple memoize, cache based arguments with adjustable limit. + * + * @param {Function} func + * @param {object} options + * @param {number} options.cacheLimit + * @returns {Function} + */ +const memo = (func, { cacheLimit = 1 } = {}) => { + const ized = function () { + const cache = []; + + return (...args) => { + const key = JSON.stringify({ value: [...args].map(arg => (typeof arg === 'function' && arg.toString()) || arg) }); + const keyIndex = cache.indexOf(key); + + if (keyIndex < 0) { + const result = func.call(null, ...args); + cache.unshift(key, result); + cache.length = cacheLimit * 2; + return cache[1]; + } + + return cache[keyIndex + 1]; + }; + }; + + return ized(); +}; + /** * An empty function. * Typically used as a default prop. @@ -134,6 +164,35 @@ const numberDisplay = value => { return numbro(value); }; +/** + * Recursive object and props freeze/immutable. + * Used from deep-freeze-strict, an older npm package, license - public domain + * https://bit.ly/3HR4XWP and https://bit.ly/3Ye4S6B + * + * @param {object} obj + * @returns {*} + */ +const objFreeze = obj => { + Object.freeze(obj); + + const oIsFunction = typeof obj === 'function'; + const hasOwnProp = Object.prototype.hasOwnProperty; + + Object.getOwnPropertyNames(obj).forEach(prop => { + if ( + hasOwnProp.call(obj, prop) && + (oIsFunction ? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments' : true) && + obj[prop] !== null && + (typeof obj[prop] === 'object' || typeof obj[prop] === 'function') && + !Object.isFrozen(obj[prop]) + ) { + objFreeze(obj[prop]); + } + }); + + return obj; +}; + /** * Is dev mode active. * Associated with using the NPM script "start". See dotenv config files for activation. @@ -366,10 +425,12 @@ const helpers = { generateId, isDate, isPromise, + memo, noop, noopPromise, noopTranslate, numberDisplay, + objFreeze, DEV_MODE, PROD_MODE, REVIEW_MODE, diff --git a/src/components/authentication/__tests__/__snapshots__/authenticationContext.test.js.snap b/src/components/authentication/__tests__/__snapshots__/authenticationContext.test.js.snap index d2461fa06..3102f36f1 100644 --- a/src/components/authentication/__tests__/__snapshots__/authenticationContext.test.js.snap +++ b/src/components/authentication/__tests__/__snapshots__/authenticationContext.test.js.snap @@ -85,9 +85,6 @@ exports[`AuthenticationContext should apply a hook for retrieving auth data from }, ], ], - [ - [Function], - ], ] `; diff --git a/src/components/authentication/authenticationContext.js b/src/components/authentication/authenticationContext.js index b9b40a337..c516e4277 100644 --- a/src/components/authentication/authenticationContext.js +++ b/src/components/authentication/authenticationContext.js @@ -1,8 +1,8 @@ -import React, { useContext, useState } from 'react'; -import { useMount, useUnmount } from 'react-use'; +import React, { useContext } from 'react'; +import { useMount } from 'react-use'; import { reduxActions, storeHooks } from '../../redux'; import { helpers } from '../../common'; -import { routerContext, routerHelpers } from '../router'; +import { routerHelpers } from '../router'; /** * Base context. @@ -27,10 +27,8 @@ const useAuthContext = () => useContext(AuthenticationContext); * @param {string} options.appName * @param {Function} options.authorizeUser * @param {Function} options.hideGlobalFilter - * @param {Function} options.onNavigation * @param {Function} options.setAppName * @param {Function} options.useDispatch - * @param {Function} options.useHistory * @param {Function} options.useSelectorsResponse * @returns {{data: {errorCodes, errorStatus: *, locale}, pending: boolean, fulfilled: boolean, error: boolean}} */ @@ -38,14 +36,10 @@ const useGetAuthorization = ({ appName = routerHelpers.appName, authorizeUser = reduxActions.platform.authorizeUser, hideGlobalFilter = reduxActions.platform.hideGlobalFilter, - onNavigation = reduxActions.platform.onNavigation, setAppName = reduxActions.platform.setAppName, useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch, - useHistory: useAliasHistory = routerContext.useHistory, useSelectorsResponse: useAliasSelectorsResponse = storeHooks.reactRedux.useSelectorsResponse } = {}) => { - const [unregister, setUnregister] = useState(() => helpers.noop); - const history = useAliasHistory(); const dispatch = useAliasDispatch(); const { data, error, fulfilled, pending, responses } = useAliasSelectorsResponse([ { id: 'auth', selector: ({ user }) => user?.auth }, @@ -59,11 +53,6 @@ const useGetAuthorization = ({ useMount(async () => { await dispatch(authorizeUser()); dispatch([setAppName(appName), hideGlobalFilter()]); - setUnregister(() => dispatch(onNavigation(event => history.push(event.navId)))); - }); - - useUnmount(() => { - unregister(); }); const [user = {}, app = {}] = (Array.isArray(data.auth) && data.auth) || []; diff --git a/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap b/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap index 5c27b4f27..c368dfec7 100644 --- a/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap +++ b/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap @@ -263,6 +263,18 @@ exports[`I18n Component should generate a predictable locale key output snapshot { "file": "./src/components/productView/productView.js", "keys": [ + { + "key": "curiosity-inventory.tabHosts", + "match": "t('curiosity-inventory.tabHosts', { context: [productId] })", + }, + { + "key": "curiosity-inventory.tabInstances", + "match": "t('curiosity-inventory.tabInstances', { context: [productId] })", + }, + { + "key": "curiosity-inventory.tabSubscriptions", + "match": "t('curiosity-inventory.tabSubscriptions', { context: [productId] })", + }, { "key": "curiosity-inventory.tabHosts", "match": "t('curiosity-inventory.tabHosts', { context: [productId] })", @@ -277,7 +289,7 @@ exports[`I18n Component should generate a predictable locale key output snapshot }, { "key": "curiosity-view.title", - "match": "t(\`curiosity-view.title\`, { appName: helpers.UI_DISPLAY_NAME, context: updatedRouteProductLabel })", + "match": "t(\`curiosity-view.title\`, { appName: helpers.UI_DISPLAY_NAME, context: productGroup })", }, ], }, @@ -290,11 +302,20 @@ exports[`I18n Component should generate a predictable locale key output snapshot }, { "key": "curiosity-view.title", - "match": "t('curiosity-view.title', { appName: helpers.UI_DISPLAY_NAME, context: product.productId })", + "match": "t('curiosity-view.title', { appName: helpers.UI_DISPLAY_NAME, context: productId })", }, { "key": "curiosity-view.description", - "match": "t('curiosity-view.description', { appName: helpers.UI_DISPLAY_NAME, context: product.productId })", + "match": "t('curiosity-view.description', { appName: helpers.UI_DISPLAY_NAME, context: productGroup })", + }, + ], + }, + { + "file": "./src/components/router/routerContext.js", + "keys": [ + { + "key": "curiosity-view.title", + "match": "t(\`curiosity-view.title\`, { appName: helpers.UI_DISPLAY_NAME, context: firstMatch?.productGroup })", }, ], }, diff --git a/src/components/i18n/i18n.js b/src/components/i18n/i18n.js index 2bad0450d..9c52e6802 100644 --- a/src/components/i18n/i18n.js +++ b/src/components/i18n/i18n.js @@ -24,35 +24,38 @@ const I18n = ({ children, fallbackLng, loadPath, locale }) => { * Initialize i18next */ useMount(async () => { - try { - await i18next - .use(XHR) - .use(initReactI18next) - .init({ - backend: { - loadPath - }, - fallbackLng, - lng: undefined, - debug: !helpers.PROD_MODE, - ns: ['default'], - defaultNS: 'default', - react: { - useSuspense: false - } - }); - } catch (e) { - // - } + console.log('>>> mount i18n'); + if (!initialized) { + try { + await i18next + .use(XHR) + .use(initReactI18next) + .init({ + backend: { + loadPath + }, + fallbackLng, + lng: undefined, + debug: !helpers.PROD_MODE, + ns: ['default'], + defaultNS: 'default', + react: { + useSuspense: false + } + }); + } catch (e) { + // + } - setInitialized(true); + setInitialized(true); + } }); /** * Update locale. */ useEffect(() => { - if (initialized) { + if (initialized && locale) { try { i18next.changeLanguage(locale); } catch (e) { diff --git a/src/components/productView/__tests__/__snapshots__/productView.test.js.snap b/src/components/productView/__tests__/__snapshots__/productView.test.js.snap index 803ea6211..1f83cd254 100644 --- a/src/components/productView/__tests__/__snapshots__/productView.test.js.snap +++ b/src/components/productView/__tests__/__snapshots__/productView.test.js.snap @@ -5,10 +5,10 @@ exports[`ProductView Component should allow custom inventory displays via config className="" > - t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum product label"}) + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) `; -exports[`ProductView Component should allow custom product views via props: custom graphCard, descriptions 1`] = ` +exports[`ProductView Component should allow custom product views via productDisplay types: custom view, capacity 1`] = ` - t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum product label"}) + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) `; -exports[`ProductView Component should allow custom product views via props: custom toolbar, toolbarGraph 1`] = ` +exports[`ProductView Component should allow custom product views via productDisplay types: custom view, dual axes 1`] = ` - t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum product label"}) + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) `; -exports[`ProductView Component should render a basic component: basic 1`] = ` +exports[`ProductView Component should allow custom product views via productDisplay types: custom view, hourly 1`] = ` - t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum product label"}) + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) + + + + + + + + + + + + + +`; + +exports[`ProductView Component should allow custom product views via productDisplay types: custom view, legacy 1`] = ` + + + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) + + + `; -exports[`ProductView Component should render nothing if path and product parameters are empty: empty 1`] = ` +exports[`ProductView Component should allow custom product views via productDisplay types: custom view, partial 1`] = ` - t(curiosity-view.title, {"appName":"Subscriptions"}) + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) + > + + + + + + + + + + + + + + + `; -exports[`ProductView Component should use an instances inventory for rhosak: custom inventory, instances table 1`] = ` - - - + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) + + + + + + + + + + + + + + - - } - isDisabled={false} - perPageDefault={10} + + + + +`; + +exports[`ProductView Component should render nothing if productGroup, and product parameters are empty: empty, productGroup 1`] = `""`; + +exports[`ProductView Component should render nothing if productGroup, and product parameters are empty: empty, productId and viewId 1`] = ` + + + t(curiosity-view.title, {"appName":"Subscriptions","context":"lorem ipsum"}) + + - + `; diff --git a/src/components/productView/__tests__/__snapshots__/productViewContext.test.js.snap b/src/components/productView/__tests__/__snapshots__/productViewContext.test.js.snap index d08e8fd0b..8ed623631 100644 --- a/src/components/productView/__tests__/__snapshots__/productViewContext.test.js.snap +++ b/src/components/productView/__tests__/__snapshots__/productViewContext.test.js.snap @@ -27,6 +27,9 @@ exports[`ProductViewContext should apply a hook for retrieving product context: "aliases": [ "OpenShift Container Platform", "openshift", + "container", + "platform", + "shift", ], "graphTallyQuery": { "granularity": "Daily", @@ -167,6 +170,9 @@ exports[`ProductViewContext should apply a hook for retrieving product context: "aliases": [ "OpenShift Container Platform", "openshift", + "container", + "platform", + "shift", ], "graphTallyQuery": { "granularity": "Daily", diff --git a/src/components/productView/__tests__/__snapshots__/productViewMissing.test.js.snap b/src/components/productView/__tests__/__snapshots__/productViewMissing.test.js.snap index 1ab5cd824..82064ed10 100644 --- a/src/components/productView/__tests__/__snapshots__/productViewMissing.test.js.snap +++ b/src/components/productView/__tests__/__snapshots__/productViewMissing.test.js.snap @@ -3,316 +3,105 @@ exports[`ProductViewMissing Component should redirect when there are limited product cards: redirect action 1`] = ` [ [ - "openshift-container", + "lorem-ipsum", ], ] `; exports[`ProductViewMissing Component should render a basic component: basic 1`] = ` - - - t(curiosity-view.title, {"appName":"Subscriptions"}) - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"OpenShift Container Platform"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"OpenShift Container Platform"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"OpenShift-dedicated-metrics"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"OpenShift-dedicated-metrics"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"OpenShift-metrics"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"OpenShift-metrics"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"rhacs"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"rhacs"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"RHEL"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"RHEL"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"rhods"}) - - - +
- t(curiosity-view.description, {"appName":"Subscriptions","context":"rhods"}) - - - - - - - - - t(curiosity-view.title, {"appName":"Subscriptions","context":"rhosak"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"rhosak"}) - - - - - - + +
+ + + <h1 + className="pf-c-title pf-m-2xl pf-u-mb-sm" + data-ouia-component-id="OUIA-Generated-Title-1" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + widget-type="InsightsPageHeaderTitle" + > + t(curiosity-view.title, {"appName":"Subscriptions"}) + </h1> + + +
+
+ + +

+ t(curiosity-view.subtitle, {"appName":"Subscriptions","context":"missing"}, [object Object]) +

+
+ + + +
- - - t(curiosity-view.title, {"appName":"Subscriptions","context":"Satellite"}) - - - - t(curiosity-view.description, {"appName":"Subscriptions","context":"Satellite"}) - - - - - - - - + +
+
+ +
+
+ `; diff --git a/src/components/productView/__tests__/productView.test.js b/src/components/productView/__tests__/productView.test.js index 9e95458de..a0c661484 100644 --- a/src/components/productView/__tests__/productView.test.js +++ b/src/components/productView/__tests__/productView.test.js @@ -1,16 +1,13 @@ import React from 'react'; import { ProductView } from '../productView'; -import { config as rhosakConfig } from '../../../config/product.rhosak'; -import { InventoryTab } from '../../inventoryTabs/inventoryTab'; +import { RHSM_INTERNAL_PRODUCT_DISPLAY_TYPES as DISPLAY_TYPES } from '../../../services/rhsm/rhsmConstants'; describe('ProductView Component', () => { it('should render a basic component', async () => { const props = { useRouteDetail: () => ({ - pathParameter: 'lorem ipsum', - productConfig: [{ lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum' }], - productParameter: 'lorem ipsum product label', - viewParameter: 'dolor sit' + productGroup: 'lorem ipsum', + productConfig: [{ lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum' }] }) }; @@ -18,48 +15,80 @@ describe('ProductView Component', () => { expect(component).toMatchSnapshot('basic'); }); - it('should render nothing if path and product parameters are empty', async () => { + it('should render nothing if productGroup, and product parameters are empty', async () => { const props = { useRouteDetail: () => ({ - pathParameter: null, - productConfig: [], - viewParameter: null + productGroup: null, + productConfig: [] }) }; - const component = await shallowHookComponent(); - expect(component).toMatchSnapshot('empty'); + const componentProductGroup = await shallowHookComponent(); + expect(componentProductGroup).toMatchSnapshot('empty, productGroup'); + + props.useRouteDetail = () => ({ + productGroup: 'lorem ipsum', + productId: null, + viewId: null + }); + + const componentProductId = await shallowHookComponent(); + expect(componentProductId).toMatchSnapshot('empty, productId and viewId'); }); - it('should allow custom product views via props', async () => { + it('should allow custom product views via productDisplay types', async () => { const props = { - toolbarGraphDescription: true, useRouteDetail: () => ({ - pathParameter: 'lorem ipsum', - productConfig: [{ lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum' }], - productParameter: 'lorem ipsum product label', - viewParameter: 'dolor sit' + productGroup: 'lorem ipsum', + productConfig: [ + { lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', productDisplay: DISPLAY_TYPES.HOURLY } + ] }) }; - const component = await shallowHookComponent(); - expect(component).toMatchSnapshot('custom graphCard, descriptions'); + const componentTypeOne = await shallowHookComponent(); + expect(componentTypeOne).toMatchSnapshot('custom view, hourly'); - component.setProps({ - toolbarGraphDescription: false, - toolbarGraph: lorem ipsum + props.useRouteDetail = () => ({ + productGroup: 'lorem ipsum', + productConfig: [ + { lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', productDisplay: DISPLAY_TYPES.CAPACITY } + ] }); + const componentTypeTwo = await shallowHookComponent(); + expect(componentTypeTwo).toMatchSnapshot('custom view, capacity'); - expect(component).toMatchSnapshot('custom toolbar, toolbarGraph'); + props.useRouteDetail = () => ({ + productGroup: 'lorem ipsum', + productConfig: [ + { lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', productDisplay: DISPLAY_TYPES.DUAL_AXES } + ] + }); + const componentTypeThree = await shallowHookComponent(); + expect(componentTypeThree).toMatchSnapshot('custom view, dual axes'); + + props.useRouteDetail = () => ({ + productGroup: 'lorem ipsum', + productConfig: [{ lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', productDisplay: DISPLAY_TYPES.LEGACY }] + }); + const componentTypeFour = await shallowHookComponent(); + expect(componentTypeFour).toMatchSnapshot('custom view, legacy'); + + props.useRouteDetail = () => ({ + productGroup: 'lorem ipsum', + productConfig: [ + { lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', productDisplay: DISPLAY_TYPES.PARTIAL } + ] + }); + const componentTypeFive = await shallowHookComponent(); + expect(componentTypeFive).toMatchSnapshot('custom view, partial'); }); it('should allow custom inventory displays via config', async () => { const props = { toolbarGraphDescription: true, useRouteDetail: () => ({ - pathParameter: 'lorem ipsum', - productParameter: 'lorem ipsum product label', - viewParameter: 'dolor sit', + productGroup: 'lorem ipsum', productConfig: [ { lorem: 'ipsum', productId: 'lorem', viewId: 'viewIpsum', initialSubscriptionsInventoryFilters: [] } ] @@ -69,17 +98,4 @@ describe('ProductView Component', () => { const component = await shallowHookComponent(); expect(component).toMatchSnapshot('custom tabs, subscriptions table'); }); - - it('should use an instances inventory for rhosak', async () => { - const props = { - useRouteDetail: () => ({ - pathParameter: rhosakConfig.productId, - productParameter: rhosakConfig.productGroup, - productConfig: [rhosakConfig] - }) - }; - - const component = await shallowHookComponent(); - expect(component.find(InventoryTab).first()).toMatchSnapshot('custom inventory, instances table'); - }); }); diff --git a/src/components/productView/__tests__/productViewMissing.test.js b/src/components/productView/__tests__/productViewMissing.test.js index 22971f192..da5400154 100644 --- a/src/components/productView/__tests__/productViewMissing.test.js +++ b/src/components/productView/__tests__/productViewMissing.test.js @@ -1,21 +1,32 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; import { ProductViewMissing } from '../productViewMissing'; +import { store } from '../../../redux'; describe('ProductViewMissing Component', () => { - it('should render a basic component', () => { + it('should render a basic component', async () => { const props = { availableProductsRedirect: 1 }; - const component = shallow(); - expect(component).toMatchSnapshot('basic'); + + const component = await mountHookComponent( + + + + + + ); + + expect(component.find(ProductViewMissing)).toMatchSnapshot('basic'); }); it('should redirect when there are limited product cards', async () => { const mockPush = jest.fn(); const props = { availableProductsRedirect: 200, - useHistory: () => ({ push: mockPush }) + useNavigate: () => mockPush, + useRouteDetail: () => ({ firstMatch: { productPath: 'lorem-ipsum' } }) }; await mountHookComponent(); diff --git a/src/components/productView/productView.js b/src/components/productView/productView.js index d448b13e3..a7b11a7ce 100644 --- a/src/components/productView/productView.js +++ b/src/components/productView/productView.js @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; +import { useMount } from 'react-use'; import { routerContext } from '../router'; +// import { PageLayout, PageHeader, PageColumns } from '../pageLayout/pageLayout'; import { ProductViewContext } from './productViewContext'; import { PageLayout, PageHeader, PageSection, PageToolbar, PageMessages, PageColumns } from '../pageLayout/pageLayout'; import { GraphCard } from '../graphCard/graphCard'; @@ -13,6 +15,7 @@ import InventoryTabs, { InventoryTab } from '../inventoryTabs/inventoryTabs'; import { InventoryCardSubscriptions } from '../inventoryCardSubscriptions/inventoryCardSubscriptions'; import { RHSM_INTERNAL_PRODUCT_DISPLAY_TYPES as DISPLAY_TYPES } from '../../services/rhsm/rhsmConstants'; import { translate } from '../i18n/i18n'; +import { storeHooks } from '../../redux'; /** * Display product columns. @@ -20,12 +23,28 @@ import { translate } from '../i18n/i18n'; * @param {object} props * @param {Function} props.t * @param {Function} props.useRouteDetail + * @param {Function} props.useSelector * @returns {Node} */ -const ProductView = ({ t, useRouteDetail: useAliasRouteDetail }) => { - const { productParameter: routeProductLabel, productConfig } = useAliasRouteDetail(); - const updatedRouteProductLabel = (Array.isArray(routeProductLabel) && routeProductLabel?.[0]) || routeProductLabel; +const ProductView = ({ t, useRouteDetail: useAliasRouteDetail, useSelector: useAliasSelector }) => { + const { productGroup, productConfig } = useAliasRouteDetail(); + // const [] = useState(); + // useEffect(() => {}, []); + // const { productGroup, productConfig } = detail || {}; + // useAliasRouteDetail(); + // const { productGroup, productConfig } = useAliasSelector(({ view }) => view?.product?.config, {}); + useMount(() => { + console.log('>>>> PRODUCT VIEW MOUNTED', useAliasSelector, productGroup); + }); + + /** + * Render a product with a context provider + * + * @param {object} config + * @returns {React.ReactNode|null} + */ + /* const renderProduct = config => { const { initialInventoryFilters, initialSubscriptionsInventoryFilters, productDisplay, productId, viewId } = config; @@ -84,14 +103,82 @@ const ProductView = ({ t, useRouteDetail: useAliasRouteDetail }) => { ); }; + */ + const renderProduct = useCallback(() => { + const updated = config => { + console.log('>>>> PRODUCT VIEW', config); + const { initialInventoryFilters, initialSubscriptionsInventoryFilters, productDisplay, productId, viewId } = + config; + + if (!productId || !viewId) { + return null; + } + + return ( + + {productDisplay !== DISPLAY_TYPES.HOURLY && } + + + + + + + + + {!helpers.UI_DISABLED_TABLE_HOSTS && + productDisplay !== DISPLAY_TYPES.HOURLY && + productDisplay !== DISPLAY_TYPES.CAPACITY && + initialInventoryFilters && ( + + + + )} + {!helpers.UI_DISABLED_TABLE_INSTANCES && + productDisplay !== DISPLAY_TYPES.DUAL_AXES && + productDisplay !== DISPLAY_TYPES.LEGACY && + productDisplay !== DISPLAY_TYPES.PARTIAL && + initialInventoryFilters && ( + + + + )} + {!helpers.UI_DISABLED_TABLE_SUBSCRIPTIONS && initialSubscriptionsInventoryFilters && ( + + + + )} + + + + ); + }; + + return productConfig?.map(config => updated(config)); + }, [productConfig, t]); return ( - - - {t(`curiosity-view.title`, { appName: helpers.UI_DISPLAY_NAME, context: updatedRouteProductLabel })} - - {productConfig.map(config => renderProduct(config))} - + (productGroup && ( + + + {t(`curiosity-view.title`, { appName: helpers.UI_DISPLAY_NAME, context: productGroup })} + + {renderProduct()} + + )) || + null ); }; @@ -102,7 +189,8 @@ const ProductView = ({ t, useRouteDetail: useAliasRouteDetail }) => { */ ProductView.propTypes = { t: PropTypes.func, - useRouteDetail: PropTypes.func + useRouteDetail: PropTypes.func, + useSelector: PropTypes.func }; /** @@ -112,7 +200,8 @@ ProductView.propTypes = { */ ProductView.defaultProps = { t: translate, - useRouteDetail: routerContext.useRouteDetail + useRouteDetail: routerContext.useRouteDetail, + useSelector: storeHooks.reactRedux.useSelector }; export { ProductView as default, ProductView }; diff --git a/src/components/productView/productViewMissing.js b/src/components/productView/productViewMissing.js index 0b68816c3..50308a6a1 100644 --- a/src/components/productView/productViewMissing.js +++ b/src/components/productView/productViewMissing.js @@ -2,22 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, Card, CardBody, CardFooter, CardTitle, Gallery, Title, PageSection } from '@patternfly/react-core'; import { ArrowRightIcon } from '@patternfly/react-icons'; -import { useMount } from 'react-use'; import { PageLayout, PageHeader } from '../pageLayout/pageLayout'; -import { routerContext, routerHelpers } from '../router'; +import { routerContext } from '../router'; import { helpers } from '../../common'; import { translate } from '../i18n/i18n'; -/** - * Return a list of available products. - * - * @returns {Array} - */ -const filterAvailableProducts = () => { - const { configs, allConfigs } = routerHelpers.getRouteConfigByPath(); - return (configs.length && configs) || allConfigs; -}; - /** * Render a missing product view. * @@ -25,18 +14,24 @@ const filterAvailableProducts = () => { * @param {object} props * @param {number} props.availableProductsRedirect * @param {Function} props.t - * @param {Function} props.useHistory + * @param {Function} props.useNavigate + * @param {Function} props.useRouteDetail * @returns {Node} */ -const ProductViewMissing = ({ availableProductsRedirect, t, useHistory: useAliasHistory }) => { - const history = useAliasHistory({ isSetAppNav: true }); - const availableProducts = filterAvailableProducts(); +const ProductViewMissing = ({ + availableProductsRedirect, + t, + useNavigate: useAliasNavigate, + useRouteDetail: useAliasRouteDetail +}) => { + const navigate = useAliasNavigate(); + const { firstMatch, allConfigs } = useAliasRouteDetail(); + const availableProducts = (firstMatch && [firstMatch]) || allConfigs; - useMount(() => { - if (availableProducts.length <= availableProductsRedirect) { - history.push(availableProducts?.[0]?.productPath); - } - }); + if (availableProducts?.length <= availableProductsRedirect) { + navigate(availableProducts[0].productPath); + return null; + } /** * On click, update history. @@ -45,38 +40,41 @@ const ProductViewMissing = ({ availableProductsRedirect, t, useHistory: useAlias * @param {string} path * @returns {void} */ - const onNavigate = path => history.push(path); + const onNavigate = path => navigate(path); return ( {t(`curiosity-view.title`, { appName: helpers.UI_DISPLAY_NAME })} - {availableProducts.map(product => ( + {availableProducts?.map(({ productGroup, productId, productPath }) => ( onNavigate(product.productPath)} + onClick={() => onNavigate(productPath)} > {t('curiosity-view.title', { appName: helpers.UI_DISPLAY_NAME, - context: product.productId + context: productId })} {t('curiosity-view.description', { appName: helpers.UI_DISPLAY_NAME, - context: product.productId + context: productGroup })}