diff --git a/src/modules/navigation/NavLink.jsx b/src/modules/navigation/NavLink.jsx index cddcf792b9..a95b20bf86 100644 --- a/src/modules/navigation/NavLink.jsx +++ b/src/modules/navigation/NavLink.jsx @@ -1,6 +1,6 @@ import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import { NavLink as UINavLink } from 'cozy-ui/transpiled/react/Nav' @@ -19,6 +19,21 @@ const NavLink = ({ clickState: [lastClicked, setLastClicked] }) => { const location = useLocation() + const prevPathnameRef = useRef(location.pathname) + + useEffect(() => { + const prevPathname = prevPathnameRef.current + prevPathnameRef.current = location.pathname + + if (!lastClicked) return + + if (navLinkMatch(rx, lastClicked, location.pathname)) { + setLastClicked(null) // route arrived at destination + } else if (location.pathname !== prevPathname) { + setLastClicked(null) // route changed but went elsewhere (e.g. back button) + } + }, [location.pathname, rx, lastClicked, setLastClicked]) + const pathname = lastClicked ? lastClicked : location.pathname const isActive = navLinkMatch(rx, to, pathname) return ( diff --git a/src/modules/navigation/NavLink.spec.jsx b/src/modules/navigation/NavLink.spec.jsx new file mode 100644 index 0000000000..38035e6327 --- /dev/null +++ b/src/modules/navigation/NavLink.spec.jsx @@ -0,0 +1,161 @@ +import { render, fireEvent, act } from '@testing-library/react' +import React from 'react' +import { useLocation } from 'react-router-dom' + +import { NavLink } from './NavLink' + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn() +})) + +jest.mock('cozy-ui/transpiled/react/Nav', () => ({ + NavLink: { className: 'nav-link', activeClassName: 'active' } +})) + +describe('NavLink', () => { + beforeEach(() => { + useLocation.mockReturnValue({ pathname: '/recent' }) + }) + + describe('click handler', () => { + it('calls setLastClicked with to on click', () => { + const setLastClicked = jest.fn() + const { getByRole } = render( + + Drive + + ) + fireEvent.click(getByRole('link')) + expect(setLastClicked).toHaveBeenCalledWith('/folder') + }) + + it('prevents default when no to prop', () => { + const setLastClicked = jest.fn() + const { getByRole } = render( + Drive + ) + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + getByRole('link').dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + }) + + describe('optimistic active state', () => { + it('shows as active when lastClicked matches rx', () => { + const rx = /\/(folder|nextcloud)(\/.*)?/ + const { getByRole } = render( + + Drive + + ) + expect(getByRole('link').className).toContain('active') + }) + + it('is not active when lastClicked does not match rx', () => { + const rx = /\/(folder|nextcloud)(\/.*)?/ + const { getByRole } = render( + + Drive + + ) + expect(getByRole('link').className).not.toContain('active') + }) + }) + + describe('useEffect: reset lastClicked when route arrives', () => { + it('does not reset lastClicked while the route has not yet changed', () => { + const setLastClicked = jest.fn() + useLocation.mockReturnValue({ pathname: '/recent' }) + + render( + + Drive + + ) + + expect(setLastClicked).not.toHaveBeenCalledWith(null) + }) + + it('resets lastClicked once the route matches rx', async () => { + const setLastClicked = jest.fn() + useLocation.mockReturnValue({ pathname: '/recent' }) + const rx = /\/(folder|nextcloud)(\/.*)?/ + + const { rerender } = render( + + Drive + + ) + + useLocation.mockReturnValue({ pathname: '/folder' }) + await act(async () => { + rerender( + + Drive + + ) + }) + + expect(setLastClicked).toHaveBeenCalledWith(null) + }) + + it('resets lastClicked once the route matches without rx', async () => { + const setLastClicked = jest.fn() + useLocation.mockReturnValue({ pathname: '/recent' }) + + const { rerender } = render( + + Drive + + ) + + useLocation.mockReturnValue({ pathname: '/folder' }) + await act(async () => { + rerender( + + Drive + + ) + }) + + expect(setLastClicked).toHaveBeenCalledWith(null) + }) + + it('resets lastClicked when route changes to a different destination (e.g. back button)', async () => { + const setLastClicked = jest.fn() + useLocation.mockReturnValue({ pathname: '/recent' }) + const rx = /\/(folder|nextcloud)(\/.*)?/ + + const { rerender } = render( + + Drive + + ) + + // Route changed but to /trash, not /folder (e.g. back button or unexpected navigation) + useLocation.mockReturnValue({ pathname: '/trash' }) + await act(async () => { + rerender( + + Drive + + ) + }) + + expect(setLastClicked).toHaveBeenCalledWith(null) + }) + }) +})