From 859c8469331c459b1e61c0b72a5f06af90ecd96b Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 27 Sep 2024 22:21:52 -0400 Subject: [PATCH] DEV restructure hooks --- src/components/productView/productView.js | 4 +- src/components/router/routerContext.js | 259 ++++++---------------- src/components/router/routerHelpers.js | 81 ++++--- src/redux/reducers/viewReducer.js | 16 +- 4 files changed, 127 insertions(+), 233 deletions(-) diff --git a/src/components/productView/productView.js b/src/components/productView/productView.js index ce244946c..827b05bcd 100644 --- a/src/components/productView/productView.js +++ b/src/components/productView/productView.js @@ -34,7 +34,7 @@ import { ProductViewMissing } from './productViewMissing'; * @returns {React.ReactNode} */ const ProductView = ({ t, useRouteDetail: useAliasRouteDetail }) => { - const { disableIsClosest, firstMatch, productGroup } = useAliasRouteDetail(); + const { disableIsClosestMatch, firstMatch, productGroup } = useAliasRouteDetail(); const renderProduct = useCallback(() => { const updated = config => { @@ -86,7 +86,7 @@ const ProductView = ({ t, useRouteDetail: useAliasRouteDetail }) => { return updated(firstMatch); }, [firstMatch, t]); - if (disableIsClosest) { + if (disableIsClosestMatch) { return ; } diff --git a/src/components/router/routerContext.js b/src/components/router/routerContext.js index d4f66575a..4da49e355 100644 --- a/src/components/router/routerContext.js +++ b/src/components/router/routerContext.js @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; -import { useLocation as useLocationRU } from 'react-use'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; +import { useShallowCompareEffect, useLocation } from 'react-use'; import { routerHelpers } from './routerHelpers'; import { helpers } from '../../common/helpers'; -import { storeHooks, reduxTypes } from '../../redux'; +import { storeHooks } from '../../redux'; import { translate } from '../i18n/i18n'; /** @@ -11,64 +11,21 @@ import { translate } from '../i18n/i18n'; * @module RouterContext */ -/** - * Combine react-use useLocation with actual window location. - * Focused on exposing replace and href. - * - * @param {object} options - * @param {Function} options.useLocation - * @param {*} options.windowLocation - * @returns {{_id, search, hash}} - */ -const useLocation = ({ - useLocation: useAliasLocation = useLocationRU, - windowLocation: aliasWindowLocation = window.location -} = {}) => { - useAliasLocation(); - const windowLocation = aliasWindowLocation; - const [updatedLocation, setUpdatedLocation] = useState({}); - const forceUpdateLocation = useCallback(() => { - const _id = helpers.generateHash(windowLocation); - if (updatedLocation?._id !== _id) { - setUpdatedLocation({ - ...windowLocation, - _id - }); - } - }, [updatedLocation?._id, windowLocation]); - - useEffect(() => { - const _id = helpers.generateHash(windowLocation); - if (updatedLocation?._id !== _id) { - setUpdatedLocation({ - ...windowLocation, - _id, - updateLocation: forceUpdateLocation - }); - } - }, [forceUpdateLocation, updatedLocation?._id, windowLocation]); - - return updatedLocation; -}; - /** * useNavigate wrapper. Leverage useNavigate for a modified router with parallel "state" * update. Dispatches the same type leveraged by the initialize hook, useSetRouteDetail. * * @param {object} options - * @param {Function} options.useDispatch * @param {Function} options.useLocation * @param {*} options.windowHistory * @returns {Function} */ const useNavigate = ({ - useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch, useLocation: useAliasLocation = useLocation, windowHistory: aliasWindowHistory = window.history } = {}) => { const windowHistory = aliasWindowHistory; const { search = '', hash = '' } = useAliasLocation(); - const dispatch = useAliasDispatch(); return useCallback( (pathLocation, options) => { @@ -76,11 +33,6 @@ const useNavigate = ({ const { firstMatch } = routerHelpers.getRouteConfigByPath({ pathName }); if (firstMatch?.productPath) { - dispatch({ - type: reduxTypes.app.SET_PRODUCT, - config: firstMatch?.productPath - }); - return windowHistory.pushState( {}, '', @@ -91,181 +43,104 @@ const useNavigate = ({ return windowHistory.pushState({}, '', (pathName && `${pathName}${search}${hash}`) || pathLocation, options); }, - [dispatch, hash, search, windowHistory] + [hash, search, windowHistory] ); }; /** - * Initialize and store product path, parameter, in a "state" update parallel to routing. - * We're opting to use "window.location.pathname" directly since it appears to be quicker, + * Initialize and store a product path, parameter, in a "state" update parallel to variant detail. + * We're opting to use "window.location.pathname" directly because its faster. * and returns a similar structured value as useParam. * * @param {object} options - * @param {Function} options.useSelector - * @param {Function} options.useDispatch + * @param {boolean} options.disableIsClosestMatch * @param {Function} options.useLocation - * @param {*} options.windowLocation - * @returns {*|string} + * @param {Function} options.useSelector + * @returns {{ firstMatch: unknown, isClosest: boolean, productGroup: string, productConfig: Array, + * productPath: string, productVariant: string, disableIsClosestMatch:boolean }} */ -const useSetRouteDetail = ({ - useSelector: useAliasSelector = storeHooks.reactRedux.useSelectors, - useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch, +const useSetRouteProduct = ({ + disableIsClosestMatch = helpers.DEV_MODE === true, useLocation: useAliasLocation = useLocation, - windowLocation: aliasWindowLocation = window.location + useSelector: useAliasSelector = storeHooks.reactRedux.useSelector } = {}) => { - useAliasLocation(); - const dispatch = useAliasDispatch(); - const [updatedPath] = useAliasSelector([({ view }) => view?.product?.config]); - const { pathname: productPath } = aliasWindowLocation; - - useEffect(() => { - if (productPath && productPath !== updatedPath) { - dispatch({ - type: reduxTypes.app.SET_PRODUCT, - config: productPath - }); + const [product, setProduct] = useState({}); + const { pathname: productPath } = useAliasLocation(); + const productVariant = useAliasSelector(({ view }) => view?.product?.variant, {}); + + useShallowCompareEffect(() => { + let routeConfig = routerHelpers.getRouteConfigByPath({ + pathName: productPath, + isIgnoreClosest: disableIsClosestMatch + }); + + if (productVariant) { + const selectedVariant = productVariant?.[routeConfig?.firstMatch?.productGroup]; + if (selectedVariant) { + routeConfig = routerHelpers.getRouteConfigByPath({ + pathName: selectedVariant, + isIgnoreClosest: disableIsClosestMatch + }); + } } - }, [updatedPath, dispatch, productPath]); - return updatedPath; + const { configs, firstMatch, isClosest, ...config } = routeConfig; + + setProduct(() => ({ + ...config, + firstMatch, + isClosest, + productGroup: firstMatch?.productGroup, + productConfig: (configs?.length && configs) || [], + productPath, + productVariant, + disableIsClosestMatch: + (disableIsClosestMatch && isClosest) || (disableIsClosestMatch && routerHelpers.dynamicPath() === '/') + })); + }, [disableIsClosestMatch, productPath, productVariant]); + + return product; }; /** - * Get a route detail from "state". Consume useSetRouteDetail and set basis for product - * configuration context. + * Aggregate display settings and configuration. Get a product route detail. + * Consumes useSetRouteProduct to return a display configuration for use in productView context. * * @param {object} options - * @param {boolean} options.disableIsClosest * @param {Function} options.t * @param {Function} options.useChrome - * @param {Function} options.useSelectors - * @param {Function} options.useSetRouteDetail - * @returns {{baseName: string, errorRoute: object}} + * @param {useSetRouteProduct} options.useSetRouteProduct + * @returns {{firstMatch: *, isClosest: boolean, productGroup: string, productConfig: Array, productPath: string, + * productVariant: string, disableIsClosestMatch: boolean}}} */ const useRouteDetail = ({ - disableIsClosest = helpers.DEV_MODE === true, t = translate, useChrome: useAliasChrome = useChrome, - useSelectors: useAliasSelectors = storeHooks.reactRedux.useSelectors, - useSetRouteDetail: useAliasSetRouteDetail = useSetRouteDetail + useSetRouteProduct: useAliasSetRouteProduct = useSetRouteProduct } = {}) => { - useAliasSetRouteDetail(); + const product = useAliasSetRouteProduct(); const { getBundleData = helpers.noop, updateDocumentTitle = helpers.noop } = useAliasChrome(); const bundleData = getBundleData(); - const [productPath, productVariant] = useAliasSelectors([ - ({ view }) => view?.product?.config, - ({ view }) => view?.product?.variant - ]); - const [detail, setDetail] = useState({}); + const productGroup = product?.productGroup; useEffect(() => { - const updatedVariantPath = productPath; - const hashPath = helpers.generateHash({ productPath, productVariant }); - - if (updatedVariantPath && detail?._passed !== hashPath) { - // Get base configuration match - let routeConfig = routerHelpers.getRouteConfigByPath({ - pathName: updatedVariantPath - }); - - // Determine variant to display, if any - if (productVariant) { - const selectedVariant = productVariant?.[routeConfig?.firstMatch?.productGroup]; - - if (selectedVariant) { - routeConfig = routerHelpers.getRouteConfigByPath({ - pathName: selectedVariant - }); - } - } - - const { allConfigs, availableVariants, configs, firstMatch, isClosest } = routeConfig; - - // Set document title, remove pre-baked suffix - updateDocumentTitle( - `${t(`curiosity-view.title`, { - appName: helpers.UI_DISPLAY_NAME, - context: firstMatch?.productGroup - })} - ${helpers.UI_DISPLAY_NAME}${(bundleData?.bundleTitle && ` | ${bundleData?.bundleTitle}`) || ''}`, - true - ); - - // Set route detail - setDetail({ - _passed: hashPath, - allConfigs, - availableVariants, - firstMatch, - errorRoute: routerHelpers.errorRoute, - isClosest, - productGroup: firstMatch?.productGroup, - productConfig: (configs?.length && configs) || [], - productPath, - productVariant, - disableIsClosest: disableIsClosest && isClosest - }); - } - }, [bundleData?.bundleTitle, detail?._passed, disableIsClosest, productPath, productVariant, t, updateDocumentTitle]); - - return detail; -}; - -/** - * Search parameter, return - * - * @param {object} options - * @param {Function} options.useLocation - * @param {*} options.windowHistory - * @returns {Array} - */ -const useSearchParams = ({ - useLocation: useAliasLocation = useLocation, - windowHistory: aliasWindowHistory = window.history -} = {}) => { - const windowHistory = aliasWindowHistory; - const { updateLocation, search } = useAliasLocation(); - - /** - * Alias returned React Router Dom useSearchParams hook to something expected. - * This hook defaults to merging search objects instead of overwriting them. - * - * @param {object} updatedQuery - * @param {object} options - * @param {boolean} options.isMerged Merge search with existing search, or don't - * @param {string|*} options.currentSearch search returned from useLocation - */ - const setSearchParams = useCallback( - (updatedQuery, { isMerged = true, currentSearch = search } = {}) => { - let updatedSearch = {}; - - if (isMerged) { - Object.assign(updatedSearch, routerHelpers.parseSearchParams(currentSearch), updatedQuery); - } else { - updatedSearch = updatedQuery; - } - - windowHistory.pushState( - {}, - '', - `?${Object.entries(updatedSearch) - .map(([key, value]) => `${key}=${value}`) - .join('&')}` - ); - - updateLocation(); - }, - [search, updateLocation, windowHistory] - ); - - return [routerHelpers.parseSearchParams(search), setSearchParams]; + // Set platform document title, remove pre-baked suffix + updateDocumentTitle( + `${t(`curiosity-view.title`, { + appName: helpers.UI_DISPLAY_NAME, + context: productGroup + })} - ${helpers.UI_DISPLAY_NAME}${(bundleData?.bundleTitle && ` | ${bundleData?.bundleTitle}`) || ''}`, + true + ); + }, [bundleData?.bundleTitle, productGroup, t, updateDocumentTitle]); + + return product; }; const context = { - useLocation, useNavigate, useRouteDetail, - useSearchParams, - useSetRouteDetail + useSetRouteProduct }; -export { context as default, context, useLocation, useNavigate, useRouteDetail, useSearchParams, useSetRouteDetail }; +export { context as default, context, useNavigate, useRouteDetail, useSetRouteProduct }; diff --git a/src/components/router/routerHelpers.js b/src/components/router/routerHelpers.js index fc3990065..83b3a9afb 100644 --- a/src/components/router/routerHelpers.js +++ b/src/components/router/routerHelpers.js @@ -39,43 +39,70 @@ const dynamicBasePath = ({ pathName = window.location.pathname, appName: applica pathName.split(applicationName)[0]; /** - * Match pre-sorted route config entries with a path, or match with a fallback. - * This is the primary engine for curiosity routing. It can account for a full window.location.pathname - * given the appropriate alias, group, product, and/or path identifiers provided with product configuration. + * App basePath. Return a base path. * * @param {object} params * @param {string} params.pathName - * @param {Array} params.configs - * @returns {{configs: *, firstMatch: *, isClosest: boolean, allConfigs: Array}} + * @param {string} params.appName + * @returns {string} + */ +const dynamicPath = ({ pathName = window.location.pathname, appName: applicationName = helpers.UI_NAME } = {}) => + pathName.split(applicationName)[1]; + +/** + * Trim, clean, and remove irrelevant strings to help provide more exact product configuration matches. + * + * @param {object} params + * @param {string} params.pathName + * @param {string} params.appName + * @returns {string | undefined} */ -const getRouteConfigByPath = helpers.memo(({ pathName, configs = productConfig.sortedConfigs } = {}) => { - const { byAnything, byAnythingPathIds, byAnythingVariants, byProductIdConfigs } = configs(); +const cleanPath = ({ pathName, appName: applicationName = helpers.UI_NAME } = {}) => { const updatedPathName = (/^http/i.test(pathName) && new URL(pathName).pathname) || (typeof pathName === 'string' && pathName) || undefined; - const trimmedPathName = updatedPathName + + return updatedPathName ?.toLowerCase() ?.split('#')?.[0] ?.split('?')?.[0] ?.replace(/^\/*|\/*$/g, '') - ?.replace(new RegExp(helpers.UI_DISPLAY_NAME, 'i'), '') + ?.replace(new RegExp(applicationName, 'i'), '') ?.replace(/\/\//g, '/'); +}; - // Do a known comparison against alias, group, product, path identifiers - const focusedStr = byAnythingPathIds.find(value => value.toLowerCase() === trimmedPathName?.split('/')?.pop()); - - // Fallback attempt, match pathName with the closest string - const closestStr = trimmedPathName && closest(trimmedPathName, byAnythingPathIds); - const configsByAnything = byAnything?.[focusedStr || closestStr]; - const availableVariants = byAnythingVariants?.[focusedStr || closestStr]; - - return { - isClosest: !focusedStr, - allConfigs: Object.values(byProductIdConfigs), - availableVariants, - configs: configsByAnything, - firstMatch: configsByAnything?.[0] - }; -}); +/** + * Match pre-sorted route config entries with a path, or match with a fallback. + * This is the primary engine for curiosity routing. It can account for a full window.location.pathname + * given the appropriate alias, group, product, and/or path identifiers provided with product configuration. + * + * @param {object} params + * @param {string} params.pathName + * @param {Array} [params.configs] + * @param {cleanPath} [params.cleanPath] + * @returns {{configs: *, firstMatch: *, isClosest: boolean, allConfigs: Array}} + */ +const getRouteConfigByPath = helpers.memo( + ({ pathName, configs = productConfig.sortedConfigs, cleanPath: aliasCleanPath = cleanPath } = {}) => { + const { byAnything, byAnythingPathIds, byAnythingVariants, byProductIdConfigs } = configs(); + const trimmedPathName = aliasCleanPath({ pathName }); + + // Do a known comparison against alias, group, product, path identifiers + const focusedStr = byAnythingPathIds.find(value => value.toLowerCase() === trimmedPathName?.split('/')?.pop()); + + // Fallback attempt, match pathName with the closest string + const closestStr = trimmedPathName && closest(trimmedPathName, byAnythingPathIds); + const configsByAnything = byAnything?.[focusedStr || closestStr]; + const availableVariants = byAnythingVariants?.[focusedStr || closestStr]; + + return { + isClosest: !focusedStr, + allConfigs: Object.values(byProductIdConfigs), + availableVariants, + configs: configsByAnything, + firstMatch: configsByAnything?.[0] + }; + } +); /** * Parse search parameters from a string, using a set for "uniqueness" @@ -123,8 +150,10 @@ const pathJoin = helpers.memo((...paths) => { const routerHelpers = { appName, + cleanPath, dynamicBaseName, dynamicBasePath, + dynamicPath, getRouteConfigByPath, parseSearchParams, pathJoin @@ -134,8 +163,10 @@ export { routerHelpers as default, routerHelpers, appName, + cleanPath, dynamicBaseName, dynamicBasePath, + dynamicPath, getRouteConfigByPath, parseSearchParams, pathJoin diff --git a/src/redux/reducers/viewReducer.js b/src/redux/reducers/viewReducer.js index d3864624c..aaec2f006 100644 --- a/src/redux/reducers/viewReducer.js +++ b/src/redux/reducers/viewReducer.js @@ -15,7 +15,7 @@ import { RHSM_API_QUERY_SET_TYPES as RHSM_API_QUERY_TYPES } from '../../services * * @private * @type {{product: {}, graphTallyQuery: {}, inventoryHostsQuery: {}, inventorySubscriptionsQuery: {}, - * query: {}, productConfig: {}, inventoryGuestsQuery: {}}} + * query: {}, inventoryGuestsQuery: {}}} */ const initialState = { query: {}, @@ -23,8 +23,7 @@ const initialState = { inventoryGuestsQuery: {}, inventoryHostsQuery: {}, inventorySubscriptionsQuery: {}, - product: {}, - productConfig: {} + product: {} }; /** @@ -234,17 +233,6 @@ const viewReducer = (state = initialState, action) => { reset: false } ); - case reduxTypes.app.SET_PRODUCT: - return reduxHelpers.setStateProp( - 'product', - { - config: action.config - }, - { - state, - reset: false - } - ); case reduxTypes.app.SET_PRODUCT_VARIANT: return reduxHelpers.setStateProp( 'product',