From e0a5d83b0cb27601387cf829d48b061bbc198fdf Mon Sep 17 00:00:00 2001 From: dvankeke Date: Tue, 28 Jul 2026 11:06:31 +0200 Subject: [PATCH] fix: correct team switch logic --- src/components/Header.test.tsx | 203 +++++++++++++++++++++++++++++++++ src/components/Header.tsx | 30 ++++- 2 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/components/Header.test.tsx diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx new file mode 100644 index 00000000..b6933426 --- /dev/null +++ b/src/components/Header.test.tsx @@ -0,0 +1,203 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import useOffSetTop from 'hooks/useOffSetTop' +import useResponsive from 'hooks/useResponsive' +import useSettings from 'hooks/useSettings' +import { useLocalStorage } from 'hooks/useLocalStorage' +import { useSession } from 'providers/Session' +import { useGetAplTeamsQuery } from 'redux/otomiApi' +import Header from './Header' + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), + useLocation: jest.fn(), +})) + +jest.mock('hooks/useOffSetTop') +jest.mock('hooks/useResponsive') +jest.mock('hooks/useSettings') +jest.mock('hooks/useLocalStorage') +jest.mock('providers/Session') +jest.mock('redux/otomiApi') + +jest.mock('./AccountPopover', () => () => null) + +jest.mock('./animate', () => ({ + IconButtonAnimate: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})) + +jest.mock('./Iconify', () => () => null) + +jest.mock('config', () => ({ + HEADER: { + MOBILE_HEIGHT: 64, + DASHBOARD_DESKTOP_HEIGHT: 80, + DASHBOARD_DESKTOP_OFFSET_HEIGHT: 64, + }, + NAVBAR: { + DASHBOARD_WIDTH: 280, + DASHBOARD_COLLAPSE_WIDTH: 88, + }, +})) + +const mockedUseHistory = useHistory as jest.MockedFunction +const mockedUseLocation = useLocation as jest.MockedFunction +const mockedUseOffSetTop = useOffSetTop as jest.MockedFunction +const mockedUseResponsive = useResponsive as jest.MockedFunction +const mockedUseSettings = useSettings as jest.MockedFunction +const mockedUseLocalStorage = useLocalStorage as jest.MockedFunction +const mockedUseSession = useSession as jest.MockedFunction +const mockedUseGetAplTeamsQuery = useGetAplTeamsQuery as jest.MockedFunction + +const mockPush = jest.fn() +const mockSetOboTeamId = jest.fn() +const mockOnChangeView = jest.fn() + +type RenderHeaderOptions = { + pathname?: string + oboTeamId?: string + themeView?: 'team' | 'platform' + isPlatformAdmin?: boolean +} + +const renderHeader = ({ + pathname = '/teams/alpha', + oboTeamId = 'alpha', + themeView = 'team', + isPlatformAdmin = false, +}: RenderHeaderOptions = {}) => { + mockedUseLocation.mockReturnValue({ + pathname, + search: '', + hash: '', + state: undefined, + }) + + mockedUseSession.mockReturnValue({ + user: { + email: 'user@example.com', + teams: ['alpha', 'beta'], + isPlatformAdmin, + }, + oboTeamId, + setOboTeamId: mockSetOboTeamId, + } as unknown as ReturnType) + + mockedUseSettings.mockReturnValue({ + themeView, + onChangeView: mockOnChangeView, + } as unknown as ReturnType) + + mockedUseLocalStorage.mockReturnValue([undefined, jest.fn()] as unknown as ReturnType) + + mockedUseGetAplTeamsQuery.mockReturnValue({ + data: undefined, + } as unknown as ReturnType) + + return render(
) +} + +const selectTeam = async (currentTeamName: string, nextTeamName: string) => { + const teamSelect = screen.getByRole('button', { + name: currentTeamName, + }) + + fireEvent.mouseDown(teamSelect) + + const option = await screen.findByRole('option', { + name: nextTeamName, + }) + + fireEvent.click(option) +} + +describe('Header team switching', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockedUseHistory.mockReturnValue({ + push: mockPush, + } as unknown as ReturnType) + + mockedUseOffSetTop.mockReturnValue(false) + mockedUseResponsive.mockReturnValue(true) + }) + + it('switches from one team dashboard to another team dashboard', async () => { + renderHeader({ + pathname: '/teams/alpha', + }) + + await selectTeam('alpha', 'beta') + + expect(mockSetOboTeamId).toHaveBeenCalledWith('beta') + expect(mockPush).toHaveBeenCalledWith('/teams/beta') + }) + + it('preserves the section when switching from a section overview', async () => { + renderHeader({ + pathname: '/teams/alpha/services', + }) + + await selectTeam('alpha', 'beta') + + expect(mockSetOboTeamId).toHaveBeenCalledWith('beta') + expect(mockPush).toHaveBeenCalledWith('/teams/beta/services') + }) + + it('redirects a resource detail page to the section overview', async () => { + renderHeader({ + pathname: '/teams/alpha/services/myservice', + }) + + await selectTeam('alpha', 'beta') + + expect(mockSetOboTeamId).toHaveBeenCalledWith('beta') + expect(mockPush).toHaveBeenCalledWith('/teams/beta/services') + }) + + it('redirects deeply nested resource pages to the section overview', async () => { + renderHeader({ + pathname: '/teams/alpha/services/myservice/edit', + }) + + await selectTeam('alpha', 'beta') + + expect(mockSetOboTeamId).toHaveBeenCalledWith('beta') + expect(mockPush).toHaveBeenCalledWith('/teams/beta/services') + }) + + it('redirects non-team routes to the selected team dashboard', async () => { + renderHeader({ + pathname: '/some-other-page', + }) + + await selectTeam('alpha', 'beta') + + expect(mockSetOboTeamId).toHaveBeenCalledWith('beta') + expect(mockPush).toHaveBeenCalledWith('/teams/beta') + }) + + it('does nothing when the currently active team remains selected', () => { + renderHeader({ + pathname: '/teams/alpha/services', + }) + + const hiddenInput = document.querySelector('[data-cy="select-oboteam"] input') + + fireEvent.change(hiddenInput, { + target: { + value: 'alpha', + }, + }) + + expect(mockSetOboTeamId).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d111ccec..c4ee560e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query/react' import { AppBar, Box, MenuItem, Select, Stack, Toolbar, Typography, styled } from '@mui/material' +import { SelectChangeEvent } from '@mui/material/Select' import { HEADER, NAVBAR } from 'config' import useOffSetTop from 'hooks/useOffSetTop' import useResponsive from 'hooks/useResponsive' @@ -76,11 +77,13 @@ export default function Header({ onOpenSidebar, isCollapse = false, verticalLayo const isDesktop = useResponsive('up', 'lg') const history = useHistory() const { pathname } = useLocation() + const { user: { email, teams: userTeams, isPlatformAdmin }, oboTeamId: sessionOboTeamId, setOboTeamId, } = useSession() + const [localOboTeamId] = useLocalStorage('oboTeamId', undefined) const oboTeamId = sessionOboTeamId || localOboTeamId || undefined @@ -126,17 +129,32 @@ export default function Header({ onOpenSidebar, isCollapse = false, verticalLayo const getNextPathname = (nextTeamId: string): string => { if (redirectToDashboard(nextTeamId)) return '/' + if (pathname === '/apps/admin' && themeView === 'platform') return pathname - return pathname.replace(oboTeamId, nextTeamId) + + const segments = pathname.split('/').filter(Boolean) + + // Not on a team route + if (segments[0] !== 'teams') return `/teams/${nextTeamId}` + + // /teams/:teamId + if (segments.length === 2) return `/teams/${nextTeamId}` + + // /teams/:teamId/:section + // /teams/:teamId/:section/:anything + // Always return to the section overview. + return `/teams/${nextTeamId}/${segments[2]}` } - const handleChangeTeam = (event) => { + const handleChangeTeam = (event: SelectChangeEvent) => { const nextTeamId = event.target.value as string + if (nextTeamId === oboTeamId) return + const nextPathname = getNextPathname(nextTeamId) + setOboTeamId(nextTeamId) history.push(nextPathname) - event.preventDefault() } if (!teams.length && oboTeamId) teams = [{ value: oboTeamId, label: oboTeamId }] @@ -159,11 +177,14 @@ export default function Header({ onOpenSidebar, isCollapse = false, verticalLayo )} + + {themeView === 'team' && ( <> Team: + )} + {isPlatformAdmin && ( <> View: + )} +