From 1471b6e60599ddeeedefbb26e525254d730fc22c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Maro=C5=A1i?= Date: Mon, 29 Jul 2024 09:39:41 +0200 Subject: [PATCH] Migrate chrome navigation state to Jotai. (#2906) --- src/components/AppFilter/useAppFilter.ts | 5 +- src/components/Navigation/DynamicNav.tsx | 43 ++- src/hooks/useBreadcrumbsLinks.ts | 5 +- src/redux/action-types.ts | 3 - src/redux/actions.ts | 17 +- src/redux/chromeReducers.test.js | 92 ----- src/redux/chromeReducers.ts | 49 +-- src/redux/index.ts | 8 - src/redux/store.d.ts | 14 +- src/state/atoms/navigationAtom.test.ts | 72 ++++ src/state/atoms/navigationAtom.ts | 46 +++ src/utils/useNavigation.test.js | 424 ++++++----------------- src/utils/useNavigation.ts | 27 +- 13 files changed, 264 insertions(+), 541 deletions(-) create mode 100644 src/state/atoms/navigationAtom.test.ts create mode 100644 src/state/atoms/navigationAtom.ts diff --git a/src/components/AppFilter/useAppFilter.ts b/src/components/AppFilter/useAppFilter.ts index 8bf9846ad..6e8d9a002 100644 --- a/src/components/AppFilter/useAppFilter.ts +++ b/src/components/AppFilter/useAppFilter.ts @@ -1,13 +1,12 @@ import axios from 'axios'; import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; import { BundleNavigation, ChromeModule, NavItem } from '../../@types/types'; -import { ReduxState } from '../../redux/store'; 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'; +import { navigationAtom } from '../../state/atoms/navigationAtom'; const LOCAL_PREVIEW = localStorage.getItem('chrome:local-preview') === 'true'; @@ -101,7 +100,7 @@ const useAppFilter = () => { }, }, }); - const existingSchemas = useSelector(({ chrome: { navigation } }: ReduxState) => navigation); + const existingSchemas = useAtomValue(navigationAtom); const modules = useAtomValue(chromeModulesAtom); const handleBundleData = async ({ data: { id, navItems, title } }: { data: BundleNavigation }) => { diff --git a/src/components/Navigation/DynamicNav.tsx b/src/components/Navigation/DynamicNav.tsx index f9c3862b3..aa67c6817 100644 --- a/src/components/Navigation/DynamicNav.tsx +++ b/src/components/Navigation/DynamicNav.tsx @@ -3,12 +3,11 @@ import { useLoadModule } from '@scalprum/react-core'; import { Skeleton, SkeletonSize } from '@redhat-cloud-services/frontend-components/Skeleton'; import { NavItem } from '@patternfly/react-core/dist/dynamic/components/Nav'; import { useLocation } from 'react-router-dom'; -import { useDispatch, useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; import ChromeNavItem from './ChromeNavItem'; -import { loadLeftNavSegment } from '../../redux/actions'; -import { ReduxState } from '../../redux/store'; import { DynamicNavProps, NavItem as NavItemType, Navigation } from '../../@types/types'; +import { useSetAtom } from 'jotai'; +import { getDynamicSegmentItemsAtom, getNavigationSegmentAtom, setNavigationSegmentAtom } from '../../state/atoms/navigationAtom'; const toArray = (value: NavItemType | NavItemType[]) => (Array.isArray(value) ? value : [value]); const mergeArrays = (orig: any[], index: number, value: any[]) => [...orig.slice(0, index), ...toArray(value), ...orig.slice(index)]; @@ -20,11 +19,11 @@ const isRootNavigation = (schema?: Navigation | NavItemType[]): schema is Naviga const HookedNavigation = ({ useNavigation, dynamicNav, pathname, ...props }: DynamicNavProps) => { const currentNamespace = pathname.split('/')[1]; const [isLoaded, setIsLoaded] = useState(false); - const dispatch = useDispatch(); - const schema = useSelector(({ chrome: { navigation } }: ReduxState) => navigation[currentNamespace]); - const currNav = useSelector(({ chrome: { navigation } }: ReduxState) => - (navigation[currentNamespace] as Navigation | undefined)?.navItems?.filter((item) => item.dynamicNav === dynamicNav) - ); + const getSchema = useSetAtom(getNavigationSegmentAtom); + const getCurrNav = useSetAtom(getDynamicSegmentItemsAtom); + const setnavigationSegment = useSetAtom(setNavigationSegmentAtom); + const schema = getSchema(currentNamespace); + const currNav = getCurrNav(currentNamespace, dynamicNav); const newNav = useNavigation({ schema, dynamicNav, currentNamespace, currNav }); useEffect(() => { if (newNav) { @@ -36,21 +35,19 @@ const HookedNavigation = ({ useNavigation, dynamicNav, pathname, ...props }: Dyn if (!isEqual(newValue, currNav) && isRootNavigation(schema)) { const currNavIndex = schema.navItems.findIndex((item) => item.dynamicNav === dynamicNav); if (currNavIndex !== -1) { - dispatch( - loadLeftNavSegment( - { - ...schema, - navItems: mergeArrays( - schema.navItems.filter((item) => !(item.dynamicNav && item.dynamicNav === dynamicNav)), - currNavIndex, - newValue - ), - }, - currentNamespace, - pathname, - true - ) - ); + setnavigationSegment({ + schema: { + ...schema, + navItems: mergeArrays( + schema.navItems.filter((item) => !(item.dynamicNav && item.dynamicNav === dynamicNav)), + currNavIndex, + newValue + ), + }, + segment: currentNamespace, + pathname, + shouldMerge: true, + }); } } setIsLoaded(true); diff --git a/src/hooks/useBreadcrumbsLinks.ts b/src/hooks/useBreadcrumbsLinks.ts index ccbfff798..20b0d743b 100644 --- a/src/hooks/useBreadcrumbsLinks.ts +++ b/src/hooks/useBreadcrumbsLinks.ts @@ -1,20 +1,19 @@ -import { useSelector } from 'react-redux'; import { useEffect, useMemo, useState } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; import { Required } from 'utility-types'; -import { ReduxState } from '../redux/store'; import useBundle from './useBundle'; import { NavItem } from '../@types/types'; import { findNavLeafPath } from '../utils/common'; import { extractNavItemGroups, isNavItems } from '../utils/fetchNavigationFiles'; import { useAtomValue } from 'jotai'; import { moduleRoutesAtom } from '../state/atoms/chromeModuleAtom'; +import { navigationAtom } from '../state/atoms/navigationAtom'; const useBreadcrumbsLinks = () => { const { bundleId, bundleTitle } = useBundle(); const routes = useAtomValue(moduleRoutesAtom); - const navigation = useSelector(({ chrome: { navigation } }: ReduxState) => navigation); + const navigation = useAtomValue(navigationAtom); const { pathname } = useLocation(); const [segments, setSegments] = useState[]>([]); const wildCardRoutes = useMemo(() => routes.map((item) => ({ ...item, path: `${item.path}/*` })), [routes]); diff --git a/src/redux/action-types.ts b/src/redux/action-types.ts index f316d96d7..d42236008 100644 --- a/src/redux/action-types.ts +++ b/src/redux/action-types.ts @@ -11,9 +11,6 @@ export const GLOBAL_FILTER_UPDATE = '@@chrome/global-filter-update'; export const GLOBAL_FILTER_TOGGLE = '@@chrome/global-filter-toggle'; export const GLOBAL_FILTER_REMOVE = '@@chrome/global-filter-remove'; -export const LOAD_NAVIGATION_LANDING_PAGE = '@@chrome/load-navigation-landing-page'; -export const LOAD_LEFT_NAVIGATION_SEGMENT = '@@chrome/load-navigation-segment'; - export const UPDATE_DOCUMENT_TITLE_REDUCER = '@@chrome/update-document-title'; export const MARK_ACTIVE_PRODUCT = '@@chrome/mark-active-product'; diff --git a/src/redux/actions.ts b/src/redux/actions.ts index 3dfc6fd13..e4c56f0ad 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -2,7 +2,7 @@ import * as actionTypes from './action-types'; import { getAllSIDs, getAllTags, getAllWorkloads } from '../components/GlobalFilter/tagsApi'; import type { TagFilterOptions, TagPagination } from '../components/GlobalFilter/tagsApi'; import type { ChromeUser } from '@redhat-cloud-services/types'; -import type { FlagTagsFilter, NavItem, Navigation } from '../@types/types'; +import type { FlagTagsFilter } from '../@types/types'; import type { QuickStart } from '@patternfly/quickstarts'; export function userLogIn(user: ChromeUser | boolean) { @@ -75,21 +75,6 @@ export function removeGlobalFilter(isHidden = true) { }; } -export const loadNavigationLandingPage = (schema: NavItem[]) => ({ - type: actionTypes.LOAD_NAVIGATION_LANDING_PAGE, - payload: schema, -}); - -export const loadLeftNavSegment = (schema: Navigation, segment: string, pathName: string, shouldMerge?: boolean) => ({ - type: actionTypes.LOAD_LEFT_NAVIGATION_SEGMENT, - payload: { - segment, - schema, - pathName, - shouldMerge, - }, -}); - /** * @deprecated */ diff --git a/src/redux/chromeReducers.test.js b/src/redux/chromeReducers.test.js index 36f393624..07e6946d7 100644 --- a/src/redux/chromeReducers.test.js +++ b/src/redux/chromeReducers.test.js @@ -106,98 +106,6 @@ describe('Reducers', () => { }); }); - describe('loadNavigationSegmentReducer', () => { - const navigation = { test: { navItems: [], sortedLinks: [] } }; - it('should create new segment', () => { - const newNav = { - navItems: [ - { - href: '/something', - }, - ], - }; - const result = reducers.loadNavigationSegmentReducer( - { - navigation: {}, - }, - { - payload: { - segment: 'test', - schema: newNav, - }, - } - ); - expect(result).toEqual({ - navigation: { - ...navigation, - test: { - ...navigation.test, - navItems: newNav.navItems, - sortedLinks: ['/something'], - }, - }, - }); - }); - - it('should replace schema', () => { - const newNav = { navItems: [{ href: '/another' }, { href: '/different' }] }; - const result = reducers.loadNavigationSegmentReducer( - { - navigation: { - ...navigation, - test: { - ...navigation.test, - navItems: [{ href: '/something' }], - sortedLinks: ['/something'], - }, - }, - }, - { - payload: { - segment: 'test', - schema: newNav, - shouldMerge: true, - }, - } - ); - expect(result).toEqual({ - navigation: { - ...navigation, - test: { - ...navigation.test, - navItems: newNav.navItems, - sortedLinks: ['/different', '/another'], - }, - }, - }); - }); - - it('should highlight items', () => { - const result = reducers.loadNavigationSegmentReducer( - { - navigation: {}, - }, - { - payload: { - segment: 'test', - schema: { navItems: [{ href: '/something' }] }, - pathName: '/something', - }, - } - ); - expect(result).toEqual({ - navigation: { - ...navigation, - test: { - ...navigation.test, - navItems: [{ href: '/something', active: true }], - sortedLinks: ['/something'], - }, - }, - }); - }); - }); - describe('Add Quickstarts to App', () => { let prevState; beforeEach( diff --git a/src/redux/chromeReducers.ts b/src/redux/chromeReducers.ts index 83abdb790..0db46961e 100644 --- a/src/redux/chromeReducers.ts +++ b/src/redux/chromeReducers.ts @@ -1,7 +1,6 @@ import { QuickStart } from '@patternfly/quickstarts'; import { ChromeUser } from '@redhat-cloud-services/types'; -import { NavItem, Navigation } from '../@types/types'; -import { ITLess, highlightItems, levelArray } from '../utils/common'; +import { ITLess } from '../utils/common'; import { ChromeState } from './store'; export function loginReducer(state: ChromeState, { payload }: { payload: ChromeUser }): ChromeState { @@ -26,52 +25,6 @@ export function onPageObjectId(state: ChromeState, { payload }: { payload: strin }; } -export function loadNavigationLandingPageReducer(state: ChromeState, { payload }: { payload: NavItem[] }): ChromeState { - return { - ...state, - navigation: { - ...state.navigation, - landingPage: payload, - }, - }; -} - -function isNavigation(nav?: Navigation | NavItem[]): nav is Navigation { - return !Array.isArray(nav); -} - -export function loadNavigationSegmentReducer( - state: ChromeState, - { - payload: { segment, schema, pathName, shouldMerge }, - }: { - payload: { - segment: string; - schema: Navigation; - pathName: string; - shouldMerge?: boolean; - }; - } -): ChromeState { - const mergedSchema = shouldMerge || !state.navigation?.[segment] ? schema : state.navigation?.[segment]; - if (isNavigation(mergedSchema)) { - // Landing page navgation has different siganture - const sortedLinks = levelArray(mergedSchema?.navItems).sort((a, b) => (a.length < b.length ? 1 : -1)); - return { - ...state, - navigation: { - ...state.navigation, - [segment]: { - ...mergedSchema, - navItems: pathName ? highlightItems(pathName, mergedSchema.navItems, sortedLinks) : mergedSchema.navItems, - sortedLinks, - }, - }, - }; - } - return state; -} - export function populateQuickstartsReducer( state: ChromeState, { payload: { app, quickstarts } }: { payload: { app: string; quickstarts: QuickStart[] } } diff --git a/src/redux/index.ts b/src/redux/index.ts index 0c5b8e5dc..04154b0c2 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -5,8 +5,6 @@ import { clearQuickstartsReducer, disableQuickstartsReducer, documentTitleReducer, - loadNavigationLandingPageReducer, - loadNavigationSegmentReducer, loginReducer, markActiveProduct, onPageAction, @@ -39,8 +37,6 @@ import { GLOBAL_FILTER_SCOPE, GLOBAL_FILTER_TOGGLE, GLOBAL_FILTER_UPDATE, - LOAD_LEFT_NAVIGATION_SEGMENT, - LOAD_NAVIGATION_LANDING_PAGE, MARK_ACTIVE_PRODUCT, POPULATE_QUICKSTARTS_CATALOG, UPDATE_DOCUMENT_TITLE_REDUCER, @@ -53,8 +49,6 @@ const reducers = { [USER_LOGIN]: loginReducer, [CHROME_PAGE_ACTION]: onPageAction, [CHROME_PAGE_OBJECT]: onPageObjectId, - [LOAD_NAVIGATION_LANDING_PAGE]: loadNavigationLandingPageReducer, - [LOAD_LEFT_NAVIGATION_SEGMENT]: loadNavigationSegmentReducer, [POPULATE_QUICKSTARTS_CATALOG]: populateQuickstartsReducer, [ADD_QUICKSTARTS_TO_APP]: addQuickstartstoApp, [DISABLE_QUICKSTARTS]: disableQuickstartsReducer, @@ -78,7 +72,6 @@ const globalFilter = { export const chromeInitialState: ReduxState = { chrome: { - navigation: {}, quickstarts: { quickstarts: {}, }, @@ -93,7 +86,6 @@ export default function (): { return { chrome: ( state = { - navigation: {}, quickstarts: { quickstarts: {}, }, diff --git a/src/redux/store.d.ts b/src/redux/store.d.ts index 50df23a64..e517a1417 100644 --- a/src/redux/store.d.ts +++ b/src/redux/store.d.ts @@ -1,23 +1,13 @@ import { QuickStart } from '@patternfly/quickstarts'; -import { FlagTagsFilter, NavItem, Navigation } from '../@types/types'; - -export type InternalNavigation = { - [key: string]: Navigation | NavItem[] | undefined; - landingPage?: NavItem[]; -}; +import { FlagTagsFilter } from '../@types/types'; export type ChromeState = { activeProduct?: string; missingIDP?: boolean; pageAction?: string; pageObjectId?: string; - navigation: InternalNavigation; - // accessRequests: { - // count: number; - // data: AccessRequest[]; - // hasUnseen: boolean; - // }; + // navigation: InternalNavigation; initialHash?: string; quickstarts: { disabled?: boolean; diff --git a/src/state/atoms/navigationAtom.test.ts b/src/state/atoms/navigationAtom.test.ts new file mode 100644 index 000000000..18ee5a87f --- /dev/null +++ b/src/state/atoms/navigationAtom.test.ts @@ -0,0 +1,72 @@ +import { createStore } from 'jotai'; + +import { getDynamicSegmentItemsAtom, getNavigationSegmentAtom, navigationAtom, setNavigationSegmentAtom } from './navigationAtom'; + +describe('navigationAtom', () => { + test('should highlight nav item', () => { + const store = createStore(); + store.set(navigationAtom, {}); + store.set(setNavigationSegmentAtom, { + segment: 'test', + schema: { navItems: [{ title: 'test', href: '/test' }], sortedLinks: [] }, + pathname: '/test', + }); + const navigation = store.get(navigationAtom); + expect(navigation['test']).toBeTruthy(); + const navItem = navigation['test'].navItems[0]; + expect(navItem.active).toEqual(true); + }); + + test('should replace schema', () => { + const store = createStore(); + store.set(navigationAtom, {}); + store.set(setNavigationSegmentAtom, { + segment: 'test', + schema: { navItems: [{ title: 'test', href: '/test' }], sortedLinks: [] }, + pathname: '/test', + }); + let navigation = store.get(navigationAtom); + expect(navigation['test']).toBeTruthy(); + expect(navigation['test'].navItems[0].title).toEqual('test'); + + store.set(setNavigationSegmentAtom, { + segment: 'test', + schema: { navItems: [{ title: 'test2', href: '/test2' }], sortedLinks: [] }, + pathname: '/test2', + shouldMerge: true, + }); + navigation = store.get(navigationAtom); + expect(navigation['test']).toBeTruthy(); + expect(navigation['test'].navItems[0].title).toEqual('test2'); + }); + + test('should create new navigation sergment', () => { + const schema = { navItems: [], sortedLinks: [] }; + const store = createStore(); + store.set(navigationAtom, {}); + store.set(setNavigationSegmentAtom, { + segment: 'test', + schema, + pathname: '/test', + }); + const navigation = store.get(navigationAtom); + expect(navigation['test']).toBeTruthy(); + expect(navigation['test']).toEqual(schema); + }); + + test('should get navigation segment', () => { + const store = createStore(); + store.set(navigationAtom, { test: { navItems: [{ title: 'test', href: '/test' }], sortedLinks: [] } }); + const segment = store.set(getNavigationSegmentAtom, 'test'); + expect(segment).toBeTruthy(); + expect(segment.navItems[0].title).toEqual('test'); + }); + + test('should get dynamic segment items', () => { + const store = createStore(); + store.set(navigationAtom, { test: { navItems: [{ title: 'foobar', href: '/test', dynamicNav: 'dynamic' }], sortedLinks: [] } }); + const items = store.set(getDynamicSegmentItemsAtom, 'test', 'dynamic'); + expect(items).toBeTruthy(); + expect(items[0].title).toEqual('foobar'); + }); +}); diff --git a/src/state/atoms/navigationAtom.ts b/src/state/atoms/navigationAtom.ts new file mode 100644 index 000000000..66e2c0396 --- /dev/null +++ b/src/state/atoms/navigationAtom.ts @@ -0,0 +1,46 @@ +import { atom } from 'jotai'; +import { NavItem, Navigation } from '../../@types/types.d'; +import { highlightItems, levelArray } from '../../utils/common'; + +export type InternalNavigation = { + [key: string]: Navigation; +}; + +type SetSegmentPayload = { + segment: string; + schema: Navigation; + pathname?: string; + shouldMerge?: boolean; +}; + +function isNavigation(nav?: Navigation | NavItem[]): nav is Navigation { + return !Array.isArray(nav); +} + +export const navigationAtom = atom({}); +export const setNavigationSegmentAtom = atom(null, (_get, set, { segment, schema, pathname, shouldMerge }: SetSegmentPayload) => { + set(navigationAtom, (prev) => { + const mergedSchema = shouldMerge || !prev[segment] ? schema : prev[segment]; + if (isNavigation(mergedSchema)) { + const sortedLinks = levelArray(mergedSchema.navItems).sort((a, b) => (a.length < b.length ? 1 : -1)); + return { + ...prev, + [segment]: { + ...mergedSchema, + navItems: pathname ? highlightItems(pathname, mergedSchema.navItems, sortedLinks) : mergedSchema.navItems, + sortedLinks, + }, + }; + } + return prev; + }); +}); +export const getNavigationSegmentAtom = atom(null, (get, _set, segment: string) => { + const navigation = get(navigationAtom); + return navigation[segment]; +}); +export const getDynamicSegmentItemsAtom = atom(null, (get, _set, segment: string, dynamicNav: string) => { + const navigation = get(navigationAtom); + const nav = navigation[segment]; + return nav?.navItems?.filter((item) => item.dynamicNav === dynamicNav); +}); diff --git a/src/utils/useNavigation.test.js b/src/utils/useNavigation.test.js index 6f9ce9086..834d57e6e 100644 --- a/src/utils/useNavigation.test.js +++ b/src/utils/useNavigation.test.js @@ -2,9 +2,8 @@ import React, { Fragment, useEffect } from 'react'; import { act, renderHook } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; - +import { Provider as JotaiProvider, createStore } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; import useNavigation from './useNavigation'; jest.mock('axios', () => { @@ -30,6 +29,7 @@ jest.mock('@scalprum/core', () => { import * as axios from 'axios'; import FlagProvider, { UnleashClient } from '@unleash/proxy-client-react'; import { initializeVisibilityFunctions } from './VisibilitySingleton'; +import { navigationAtom } from '../state/atoms/navigationAtom'; jest.mock('@unleash/proxy-client-react', () => { const actual = jest.requireActual('@unleash/proxy-client-react'); @@ -63,19 +63,38 @@ const testClient = new UnleashClient({ fetch: () => ({}), }); +const HydrateAtoms = ({ initialValues, children }) => { + useHydrateAtoms(initialValues); + return children; +}; + +const TestProvider = ({ initialValues, children, store }) => { + useEffect(() => { + if (store) { + initialValues.forEach(([atom, value]) => { + store.set(atom, value); + }); + } + }, []); + return ( + + {children} + + ); +}; + // eslint-disable-next-line react/prop-types -const RouterDummy = ({ store, children, path }) => ( +const RouterDummy = ({ children, path, initialValues, store }) => ( - + {children} - + ); describe('useNavigation', () => { - const mockStore = configureStore(); beforeAll(() => { initializeVisibilityFunctions({ getUser() { @@ -88,25 +107,21 @@ describe('useNavigation', () => { test('should update on namespace change', async () => { const axiosGetSpy = jest.spyOn(axios.default, 'get'); - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [], - }, - ansible: { - id: 'ansible', - navItems: [], - }, - }, + const navigation = { + insights: { + id: 'insights', + navItems: [], }, - }); + ansible: { + id: 'ansible', + navItems: [], + }, + }; axiosGetSpy.mockImplementation(() => Promise.resolve({ data: { navItems: [] } })); const createWrapper = (props) => { function Wrapper({ children }) { return ( - + {children} ); @@ -144,142 +159,18 @@ describe('useNavigation', () => { describe('isHidden flag', () => { test('should propagate navigation item isHidden flag', async () => { const axiosGetSpy = jest.spyOn(axios.default, 'get'); + const store = createStore(); const navItem = { href: '/foo', title: 'bar', isHidden: true, }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, + const navigation = { + insights: { + id: 'insights', + navItems: [navItem], }, - }); - axiosGetSpy.mockImplementation(() => - Promise.resolve({ - data: { - navItems: [navItem], - }, - }) - ); - const wrapper = ({ children }) => ( - - {children} - - ); - - await act(async () => { - await renderHook(() => useNavigation(), { - wrapper, - }); - }); - - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: [ - expect.objectContaining({ - isHidden: true, - }), - ], - }, - }, - }, - ]); - }); - - test('should mark navigation item as hidden', async () => { - const axiosGetSpy = jest.spyOn(axios.default, 'get'); - const navItem = { - href: '/foo', - title: 'bar', - permissions: [ - { - method: 'isOrgAdmin', - }, - ], - }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, - }, - }); - axiosGetSpy.mockImplementation(() => - Promise.resolve({ - data: { - navItems: [navItem], - }, - }) - ); - const wrapper = ({ children }) => ( - - {children} - - ); - - await act(async () => { - await renderHook(() => useNavigation(), { - wrapper, - }); - }); - - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: [ - expect.objectContaining({ - isHidden: true, - }), - ], - }, - }, - }, - ]); - }); - - test('should mark navigation group items as hidden', async () => { - const axiosGetSpy = jest.spyOn(axios.default, 'get'); - const navItem = { - groupId: 'foo', - title: 'bar', - navItems: [ - { - href: '/bar', - permissions: [ - { - method: 'isOrgAdmin', - }, - ], - }, - ], }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, - }, - }); axiosGetSpy.mockImplementation(() => Promise.resolve({ data: { @@ -288,7 +179,7 @@ describe('useNavigation', () => { }) ); const wrapper = ({ children }) => ( - + {children} ); @@ -299,97 +190,19 @@ describe('useNavigation', () => { }); }); - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: [ - expect.objectContaining({ - groupId: 'foo', - navItems: [ - expect.objectContaining({ - href: '/bar', - isHidden: true, - }), - ], - }), - ], - }, - }, - }, - ]); - }); - - test('should mark expandable item routes as hidden', async () => { - const axiosGetSpy = jest.spyOn(axios.default, 'get'); - const navItem = { - title: 'bar', - expandable: true, - routes: [ - { - href: '/bar', - permissions: [ - { - method: 'isOrgAdmin', - }, - ], - }, - ], - }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, + const data = store.get(navigationAtom); + + expect(data).toEqual({ + insights: { + id: 'insights', + navItems: [ + expect.objectContaining({ + isHidden: true, + }), + ], + sortedLinks: ['/foo'], }, }); - axiosGetSpy.mockImplementation(() => - Promise.resolve({ - data: { - navItems: [navItem], - }, - }) - ); - const wrapper = ({ children }) => ( - - {children} - - ); - - await act(async () => { - await renderHook(() => useNavigation(), { - wrapper, - }); - }); - - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: [ - expect.objectContaining({ - expandable: true, - routes: [ - expect.objectContaining({ - href: '/bar', - isHidden: true, - }), - ], - }), - ], - }, - }, - }, - ]); }); }); @@ -425,16 +238,13 @@ describe('useNavigation', () => { }, ], }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, + const navigation = { + insights: { + id: 'insights', + navItems: [navItem], }, - }); + }; + const store = createStore(); axiosGetSpy.mockImplementation(() => Promise.resolve({ data: { @@ -443,7 +253,7 @@ describe('useNavigation', () => { }) ); const wrapper = ({ children }) => ( - + {children} ); @@ -454,18 +264,14 @@ describe('useNavigation', () => { }); }); - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: expect.any(Array), - }, - }, + const data = store.get(navigationAtom); + expect(data).toEqual({ + insights: { + id: 'insights', + navItems: expect.any(Array), + sortedLinks: ['/baz/bar/quaxx', '/baz/bar', '/foo/bar', '/baz', '/bar'], }, - ]); + }); }); }); @@ -476,16 +282,13 @@ describe('useNavigation', () => { title: 'bar', href: '/insights', }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, + const navigation = { + insights: { + id: 'insights', + navItems: [navItem], }, - }); + }; + const store = createStore(); axiosGetSpy.mockImplementation(() => Promise.resolve({ data: { @@ -494,7 +297,7 @@ describe('useNavigation', () => { }) ); const wrapper = ({ children }) => ( - + {children} ); @@ -505,27 +308,18 @@ describe('useNavigation', () => { }); }); - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights', - schema: { - navItems: [ - { - href: '/insights', - isHidden: false, - title: 'bar', - }, - ], - }, - }, + const data = store.get(navigationAtom); + + expect(data).toEqual({ + insights: { + id: 'insights', + navItems: [{ title: 'bar', href: '/insights', active: true }], + sortedLinks: ['/insights'], }, - ]); + }); }); - test('should mark nested /insights/dashboard nav item as its parent as active', async () => { + test.only('should mark nested /insights/dashboard nav item as its parent as active', async () => { const axiosGetSpy = jest.spyOn(axios.default, 'get'); const navItem = { expandable: true, @@ -536,16 +330,13 @@ describe('useNavigation', () => { }, ], }; - const store = mockStore({ - chrome: { - navigation: { - insights: { - id: 'insights', - navItems: [navItem], - }, - }, + const navigation = { + insights: { + id: 'insights', + navItems: [navItem], }, - }); + }; + const store = createStore(); axiosGetSpy.mockImplementation(() => Promise.resolve({ data: { @@ -554,7 +345,7 @@ describe('useNavigation', () => { }) ); const wrapper = ({ children }) => ( - + {children} ); @@ -565,30 +356,27 @@ describe('useNavigation', () => { }); }); - expect(store.getActions()).toEqual([ - { - type: '@@chrome/load-navigation-segment', - payload: { - segment: 'insights', - pathName: '/insights/dashboard', - schema: { - navItems: [ + const data = store.get(navigationAtom); + + expect(data).toEqual({ + insights: { + id: 'insights', + navItems: [ + { + expandable: true, + title: 'bar', + routes: [ { - expandable: true, - isHidden: false, - title: 'bar', - routes: [ - { - href: '/insights/dashboard', - isHidden: false, - }, - ], + href: '/insights/dashboard', + active: true, }, ], + active: true, }, - }, + ], + sortedLinks: ['/insights/dashboard'], }, - ]); + }); }); }); }); diff --git a/src/utils/useNavigation.ts b/src/utils/useNavigation.ts index d11ad4e82..467c185b8 100644 --- a/src/utils/useNavigation.ts +++ b/src/utils/useNavigation.ts @@ -1,17 +1,15 @@ import axios from 'axios'; 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 } 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'; +import { navigationAtom, setNavigationSegmentAtom } from '../state/atoms/navigationAtom'; function cleanNavItemsHref(navItem: NavItem) { const result = { ...navItem }; @@ -48,13 +46,14 @@ const appendQSSearch = (currentSearch: string, activeQuickStartID: string) => { const useNavigation = () => { const { flagsReady, flagsError } = useFlagsStatus(); const clearGatewayError = useSetAtom(clearGatewayErrorAtom); - const dispatch = useDispatch(); const location = useLocation(); const navigate = useNavigate(); const { pathname } = location; const { activeQuickStartID } = useContext(QuickStartContext); const currentNamespace = pathname.split('/')[1]; - const schema = useSelector(({ chrome: { navigation } }: ReduxState) => navigation[currentNamespace] as Navigation); + const navigation = useAtomValue(navigationAtom); + const schema = navigation[currentNamespace]; + const setNavigationSegment = useSetAtom(setNavigationSegmentAtom); const [noNav, setNoNav] = useState(false); const isPreview = useAtomValue(isPreviewAtom); @@ -70,21 +69,19 @@ const useNavigation = () => { const registerLocationObserver = (initialPathname: string, schema: Navigation) => { let prevPathname = initialPathname; - dispatch(loadLeftNavSegment(schema, currentNamespace, initialPathname)); + setNavigationSegment({ schema, segment: currentNamespace, pathname: initialPathname }); return new MutationObserver((mutations) => { mutations.forEach(() => { const newPathname = window.location.pathname; if (newPathname !== prevPathname) { prevPathname = newPathname; - batch(() => { - dispatch(loadLeftNavSegment(schema, currentNamespace, prevPathname)); - /** - * Clean gateway error on URL change - */ - if (localStorage.getItem(BLOCK_CLEAR_GATEWAY_ERROR) !== 'true') { - clearGatewayError(); - } - }); + setNavigationSegment({ schema, segment: currentNamespace, pathname: prevPathname }); + /** + * Clean gateway error on URL change + */ + if (localStorage.getItem(BLOCK_CLEAR_GATEWAY_ERROR) !== 'true') { + clearGatewayError(); + } } setTimeout(() => {