Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/src/__test__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ vi.mock('../api-service/api');

const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
setNewPage: vi.fn(),
isCollapsed: false,
toggleCollapsed: vi.fn()
};

const filterContextMock = {
Expand Down
54 changes: 51 additions & 3 deletions client/src/__test__/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,18 @@ const authContextMock = {

const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
setNewPage: vi.fn(),
isCollapsed: false,
toggleCollapsed: vi.fn()
};

describe('Sidebar Component', () => {
const renderSidebar = () => {
const renderSidebar = (sidebarValue = sidebarContextMock) => {
return render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<SidebarContext.Provider value={sidebarContextMock}>
<SidebarContext.Provider value={sidebarValue}>
<Sidebar isMobileOpen={false} setIsMobileOpen={vi.fn()} />
</SidebarContext.Provider>
</I18nextProvider>
Expand All @@ -64,6 +66,52 @@ describe('Sidebar Component', () => {
expect(dashboardElement).not.toBeNull();
expect(dashboardElement!.classList.contains('selected')).toBe(true);
});

it('should call toggleCollapsed when the collapse button is clicked', () => {
const toggleCollapsed = vi.fn();
const { getByLabelText } = renderSidebar({ ...sidebarContextMock, toggleCollapsed });
fireEvent.click(getByLabelText('Collapse sidebar'));
expect(toggleCollapsed).toHaveBeenCalled();
});
});

describe('Sidebar collapsed rail', () => {
const collapsedContextMock = {
...sidebarContextMock,
isCollapsed: true
};

const renderCollapsedSidebar = () => {
return render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<SidebarContext.Provider value={collapsedContextMock}>
<Sidebar isMobileOpen={false} setIsMobileOpen={vi.fn()} />
</SidebarContext.Provider>
</I18nextProvider>
</AuthContext.Provider>
</MemoryRouter>
);
};

it('should render the rail without labels when collapsed', () => {
const { container, queryByText } = renderCollapsedSidebar();
expect(container.querySelector('.sidebar-collapsed')).not.toBeNull();
expect(queryByText('Ricardo')).toBeNull();
expect(queryByText('Logout')).toBeNull();
});

it('should expose native title tooltips on nav items when collapsed', () => {
const { getByTitle } = renderCollapsedSidebar();
expect(getByTitle('Home')).toBeDefined();
expect(getByTitle('Logout')).toBeDefined();
});

it('should label the toggle as expand when collapsed', () => {
const { getByLabelText } = renderCollapsedSidebar();
expect(getByLabelText('Expand sidebar').getAttribute('aria-expanded')).toBe('false');
});
});

describe('Build link visibility', () => {
Expand Down
61 changes: 60 additions & 1 deletion client/src/__test__/context/SidebarProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import SidebarContext, { SidebarContextData } from '../../context/SidebarContext
const ConsumerComponent: React.FC = () => {
const {
currentPage,
setNewPage
setNewPage,
isCollapsed,
toggleCollapsed
} = useContext<SidebarContextData>(SidebarContext);

return (
<div>
<div data-testid="page">{currentPage}</div>
<div data-testid="collapsed">{String(isCollapsed)}</div>
<button
data-testid="setPage"
onClick={() => {
Expand All @@ -24,6 +27,14 @@ const ConsumerComponent: React.FC = () => {
>
Change page
</button>
<button
data-testid="toggleCollapsed"
onClick={() => {
toggleCollapsed();
}}
>
Toggle collapsed
</button>
</div>
);
};
Expand All @@ -32,6 +43,7 @@ describe('SidebarProvider', () => {
// Reset DOM and mocks for each test.
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});

afterEach(() => {
Expand Down Expand Up @@ -63,4 +75,51 @@ describe('SidebarProvider', () => {
expect(getByTestId('page').textContent).toBe('/home')
);
});

it('should default to expanded when nothing is persisted', () => {
const { getByTestId } = render(
<SidebarProvider>
<ConsumerComponent />
</SidebarProvider>
);

expect(getByTestId('collapsed').textContent).toBe('false');
});

it('should initialize collapsed state from localStorage', () => {
localStorage.setItem('SIDEBAR_COLLAPSED', 'true');
const { getByTestId } = render(
<SidebarProvider>
<ConsumerComponent />
</SidebarProvider>
);

expect(getByTestId('collapsed').textContent).toBe('true');
});

it('should toggle collapsed state and persist it to localStorage', async () => {
const { getByTestId } = render(
<SidebarProvider>
<ConsumerComponent />
</SidebarProvider>
);

await act(async () => {
userEvent.click(getByTestId('toggleCollapsed'));
});

await waitFor(() => {
expect(getByTestId('collapsed').textContent).toBe('true');
expect(localStorage.getItem('SIDEBAR_COLLAPSED')).toBe('true');
});

await act(async () => {
userEvent.click(getByTestId('toggleCollapsed'));
});

await waitFor(() => {
expect(getByTestId('collapsed').textContent).toBe('false');
expect(localStorage.getItem('SIDEBAR_COLLAPSED')).toBe('false');
});
});
});
2 changes: 1 addition & 1 deletion client/src/__test__/views/HomeNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function TestApp() {
return (
<AuthContext.Provider value={authValue}>
<FilterProvider>
<SidebarContext.Provider value={{ currentPage: '/home', setNewPage: vi.fn() }}>
<SidebarContext.Provider value={{ currentPage: '/home', setNewPage: vi.fn(), isCollapsed: false, toggleCollapsed: vi.fn() }}>
<MemoryRouter initialEntries={['/home']}>
<NavigateToForm />
<Routes>
Expand Down
4 changes: 3 additions & 1 deletion client/src/__test__/views/NoteAdd.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ const authContextMock = {

const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
setNewPage: vi.fn(),
isCollapsed: false,
toggleCollapsed: vi.fn()
};

// Mock the lang handler
Expand Down
4 changes: 3 additions & 1 deletion client/src/__test__/views/TaskAdd.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ const authContextMock = {

const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
setNewPage: vi.fn(),
isCollapsed: false,
toggleCollapsed: vi.fn()
};

describe('TaskAdd Component', () => {
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/NavButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface Props {
children: React.ReactElement;
className: string;
onClick: () => void;
title?: string;
}

/**
Expand All @@ -26,6 +27,7 @@ function NavButton(props: React.PropsWithChildren<Props>): React.ReactElement {
<a
href="#"
className={props.className}
title={props.title}
onClick={(e) => {
e.preventDefault();
props.onClick();
Expand Down
38 changes: 24 additions & 14 deletions client/src/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SidebarContext from '../../context/SidebarContext';
import NavButton from '../NavButton';
import { env } from '../../env';
import './style.scss';
import { BoxArrowRight, InfoCircleFill, PersonFill, StarFill } from 'react-bootstrap-icons';
import { BoxArrowRight, ChevronDoubleRight, InfoCircleFill, Link45deg, List, PersonFill, StarFill } from 'react-bootstrap-icons';

interface Props {
isMobileOpen: boolean;
Expand All @@ -21,7 +21,7 @@ interface Props {
*/
function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
const { signOut, user } = useContext(AuthContext);
const { currentPage, setNewPage } = useContext(SidebarContext);
const { currentPage, setNewPage, isCollapsed, toggleCollapsed } = useContext(SidebarContext);
const [lastSeen, setLastSeen] = useState('');
const { t } = useTranslation();
const build = `Build: ${env.VITE_BUILD}`;
Expand Down Expand Up @@ -78,44 +78,53 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
<i className="bi bi-list"></i>
</button>

<div className={`d-flex flex-column vh-100 sidebar ${props.isMobileOpen ? 'sidebar-mobile-open' : ''}`}>
<div className={`d-flex flex-column vh-100 sidebar ${props.isMobileOpen ? 'sidebar-mobile-open' : ''} ${isCollapsed ? 'sidebar-collapsed' : ''}`}>
<button
className="d-none d-lg-flex btn sidebar-collapse-toggle"
onClick={toggleCollapsed}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{isCollapsed ? <ChevronDoubleRight /> : <List />}
</button>

<div className="sidebar-header plus-jakarta-sans-bold">
<img src={`https://gravatar.com/avatar/${user?.gravatarImageUrl}.jpg`} alt="User icon" />
<span className="header-username">{user?.name ? user?.name : 'User'}</span>
{!isCollapsed && <span className="header-username">{user?.name ? user?.name : 'User'}</span>}
</div>

<div className="header-spacer"></div>

<Nav className="flex-column p-3 plus-jakarta-sans-thin">
<NavLink to="/home" className="mb-2" onClick={() => setNewPage('/home')}>
<NavLink to="/home" className="mb-2" title={isCollapsed ? 'Home' : undefined} onClick={() => setNewPage('/home')}>
<div className={`sidebar-nav ${isHomeSelected()}`}>
<StarFill />
Home
{!isCollapsed && 'Home'}
</div>
</NavLink>
<NavLink to="/account" className="mb-2" onClick={() => setNewPage('/account')}>
<NavLink to="/account" className="mb-2" title={isCollapsed ? t('footer_my_account') : undefined} onClick={() => setNewPage('/account')}>
<div className={`sidebar-nav ${currentPage === '/account' ? 'selected' : ''}`}>
<PersonFill />
{t('footer_my_account')}
{!isCollapsed && t('footer_my_account')}
</div>
</NavLink>
<NavLink to="/about" className="mb-2" onClick={() => setNewPage('/about')}>
<NavLink to="/about" className="mb-2" title={isCollapsed ? t('home_nav_about') : undefined} onClick={() => setNewPage('/about')}>
<div className={`sidebar-nav ${currentPage === '/about' ? 'selected' : ''}`}>
<InfoCircleFill />
{t('home_nav_about')}
{!isCollapsed && t('home_nav_about')}
</div>
</NavLink>
<NavButton className="mb-2" onClick={() => logout()}>
<NavButton className="mb-2" title={isCollapsed ? t('logout') : undefined} onClick={() => logout()}>
<div className="sidebar-nav">
<BoxArrowRight />
{t('logout')}
{!isCollapsed && t('logout')}
</div>
</NavButton>
</Nav>

{/* Footer at the bottom */}
<div className="mt-auto text-center text-muted py-3">
{lastSeen && (
{!isCollapsed && lastSeen && (
<div>
<small>{t('sidebar_last_seen', { time: lastSeen })}</small>
</div>
Expand All @@ -126,8 +135,9 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
target="_blank"
rel="noopener noreferrer"
className="footer-link"
title={isCollapsed ? build : undefined}
>
<small>{build}</small>
{isCollapsed ? <Link45deg /> : <small>{build}</small>}
</a>
</div>
</div>
Expand Down
54 changes: 53 additions & 1 deletion client/src/components/Sidebar/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,59 @@
height: 100vh;
overflow-y: auto;
z-index: 2;
transition: transform 0.3s ease;
transition: transform 0.3s ease, width 0.3s ease;
}

/* Desktop collapse toggle */
.sidebar-collapse-toggle {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
padding: 0.25rem 0.5rem;
color: var(--bs-body-color);
border: none;
background: transparent;
align-items: center;
justify-content: center;
}

/* Desktop collapsed rail */
@media (min-width: 992px) {
.sidebar-collapsed {
width: 64px;

.sidebar-collapse-toggle {
right: auto;
left: 50%;
transform: translateX(-50%);
}

.sidebar-header {
margin-left: 0;
margin-top: 60px;
text-align: center;
}

.sidebar-header img {
width: 32px;
height: 32px;
margin-right: 0;
}

.header-spacer {
display: none;
}

.sidebar-nav {
display: flex;
justify-content: center;
}

.sidebar-nav svg {
margin-right: 0;
}
}
}

/* Mobile sidebar positioning - hidden by default */
Expand Down
2 changes: 2 additions & 0 deletions client/src/context/SidebarContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { createContext } from 'react';
export interface SidebarContextData {
currentPage: string;
setNewPage: (page: string) => void;
isCollapsed: boolean;
toggleCollapsed: () => void;
}

const SidebarContext = createContext<SidebarContextData>({} as SidebarContextData);
Expand Down
Loading
Loading