Skip to content
Merged
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
190 changes: 111 additions & 79 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import {
createBrowserRouter,
Navigate,
Expand All @@ -17,8 +17,81 @@ import EmailConfirmation from './views/EmailConfirmation';
import ResetPassword from './views/ResetPassword';
import CompleteResetPassword from './views/CompleteResetPassword';
import SharedNote from './views/SharedNote';
import api from './api-service/api';
import ApiConfig from './api-service/apiConfig';
import { THEME_PENDING } from './app-constants/app-constants';
import { UserPatchRequest } from './types/UserPatchRequest';
import './styles/custom.scss';

/**
* Routes for the users who are not signed in.
* @type {RouteObject[]}
*/
const notSignedRouter: RouteObject[] = [
{
path: '/',
element: <Landing />
},
{
path: '/login',
element: <Login />
},
{
path: '/register',
element: <Register />
},
{
path: '/home',
element: <Navigate to="/login" replace />
},
{
path: '/email-confirmation',
element: <EmailConfirmation />
},
{
// The reset-password is where the password reset workflow starts
path: '/reset-password',
element: <ResetPassword />
},
{
path: '/finish-reset-password',
element: <CompleteResetPassword />
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <Navigate to="/" replace />
}
];

/**
* Routes for users who are signed in.
* @type {RouteObject[]}
*/
const signedRouter: RouteObject[] = [
{
path: '/',
element: <ProtectedRoute />,
children: [
{
element: <PrivateLayout />,
children: BrowserRoutes
}
]
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <NotFound />
}
];

/**
* The main application component that sets up routing based on the
* user's authentication status.
Expand All @@ -27,100 +100,59 @@ import './styles/custom.scss';
* @returns {React.ReactNode} The rendered component.
*/
function App(): React.ReactNode {
const { signed, loading } = useContext(AuthContext);
const { signed, loading, user } = useContext(AuthContext);
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});

/**
* Routes for the users who are not signed in.
* @type {RouteObject[]}
*/
const notSignedRouter: RouteObject[] = [
{
path: '/',
element: <Landing />
},
{
path: '/login',
element: <Login />
},
{
path: '/register',
element: <Register />
},
{
path: '/home',
element: <Navigate to="/login" replace />
},
{
path: '/email-confirmation',
element: <EmailConfirmation />
},
{
// The reset-password is where the password reset workflow starts
path: '/reset-password',
element: <ResetPassword />
},
{
path: '/finish-reset-password',
element: <CompleteResetPassword />
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <Navigate to="/" replace />
}
];

/**
* Routes for users who are signed in.
* @type {RouteObject[]}
*/
const signedRouter: RouteObject[] = [
{
path: '/',
element: <ProtectedRoute />,
children: [
{
element: <PrivateLayout />,
children: BrowserRoutes
}
]
},
{
path: '/public/notes/:token',
element: <SharedNote />
},
{
path: '*',
element: <NotFound />
}
];

/**
* Determines the appropriate router based on the user's authentication status.
* @returns The configured router.
*/
const getBrowserRouter = () => {
const browserRouter = useMemo(() => {
if (signed) {
return createBrowserRouter(signedRouter);
}
return createBrowserRouter(notSignedRouter);
};
}, [signed]);

const browserRouter = getBrowserRouter();
const patchTheme = async (newTheme: string): Promise<void> => {
const payload: UserPatchRequest = {
name: null,
email: null,
password: null,
passwordAgain: null,
lang: null,
theme: newTheme
};
try {
await api.patchJSON(ApiConfig.userUrl, payload);
localStorage.removeItem(THEME_PENDING);
}
catch {
localStorage.setItem(THEME_PENDING, newTheme);
}
};

useEffect(() => {
document.body.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);

useEffect(() => {
if (!user) {
return;
}
const pendingTheme = localStorage.getItem(THEME_PENDING);
if (pendingTheme) {
patchTheme(pendingTheme);
return;
}
setTheme(user.theme ?? 'light');
}, [user]);

const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
if (signed) {
patchTheme(newTheme);
}
};

return (
Expand Down
127 changes: 121 additions & 6 deletions client/src/__test__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import React, { act } from 'react';
import { test, vi } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import App from '../App';
import { render } from '@testing-library/react';
import AuthContext from '../context/AuthContext';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthContext, { AuthContextData } from '../context/AuthContext';
import authContextMock from './__mocks__/authContextMock';
import SidebarContext from '../context/SidebarContext';
import FilterContext from '../context/FilterContext';
import api from '../api-service/api';
import ApiConfig from '../api-service/apiConfig';
import { THEME_PENDING } from '../app-constants/app-constants';

vi.mock('../api-service/api');

const sidebarContextMock = {
currentPage: '/home',
Expand All @@ -23,10 +29,23 @@ vi.mock('react-charts', () => ({
Chart: ({ options }) => <div data-testid="mocked-chart">Mocked Chart</div>
}));

test('Renders the app', async () => {
const fakeUser = (theme: string) => ({
userId: 1,
name: 'Theme User',
email: 'theme@example.com',
admin: false,
createdAt: new Date(),
gravatarImageUrl: 'http://dummyimage.com',
lang: 'en',
lastLogin: new Date().toISOString(),
theme
});

const renderApp = async (authValue: Partial<AuthContextData> = {}) => {
let utils;
await act(async () => {
render(
<AuthContext.Provider value={authContextMock}>
utils = render(
<AuthContext.Provider value={{ ...authContextMock, ...authValue }}>
<SidebarContext.Provider value={sidebarContextMock}>
<FilterContext.Provider value={filterContextMock}>
<App />
Expand All @@ -35,4 +54,100 @@ test('Renders the app', async () => {
</AuthContext.Provider>
);
});
return utils!;
};

beforeEach(() => {
localStorage.clear();
document.body.removeAttribute('data-bs-theme');
});

test('Renders the app', async () => {
await renderApp();
});

describe('Theme handling', () => {
test('Applies the theme from localStorage on load', async () => {
localStorage.setItem('theme', 'dark');

await renderApp();

expect(document.body.getAttribute('data-bs-theme')).toBe('dark');
});

test('Toggle when signed out updates only localStorage', async () => {
const patchSpy = vi.spyOn(api, 'patchJSON');

const { getByRole } = await renderApp({ signed: false, user: undefined });

await userEvent.click(getByRole('button', { name: /dark mode/i }));

expect(document.body.getAttribute('data-bs-theme')).toBe('dark');
expect(localStorage.getItem('theme')).toBe('dark');
expect(patchSpy).not.toHaveBeenCalled();
});

test('Toggle when signed in updates the UI immediately and saves to the server', async () => {
const patchSpy = vi.spyOn(api, 'patchJSON').mockResolvedValue(undefined);

const { getByRole } = await renderApp({ signed: true, user: fakeUser('light') });

await userEvent.click(getByRole('button', { name: /dark mode/i }));

expect(document.body.getAttribute('data-bs-theme')).toBe('dark');
expect(localStorage.getItem('theme')).toBe('dark');
await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith(
ApiConfig.userUrl,
expect.objectContaining({ theme: 'dark' })
)
);
expect(localStorage.getItem(THEME_PENDING)).toBeNull();
});

test('Failed save keeps the chosen theme and marks it as pending', async () => {
vi.spyOn(api, 'patchJSON').mockRejectedValue(new Error('Network error'));

const { getByRole } = await renderApp({ signed: true, user: fakeUser('light') });

await userEvent.click(getByRole('button', { name: /dark mode/i }));

await waitFor(() =>
expect(localStorage.getItem(THEME_PENDING)).toBe('dark')
);
expect(document.body.getAttribute('data-bs-theme')).toBe('dark');
expect(localStorage.getItem('theme')).toBe('dark');
});

test('Server theme wins over a stale localStorage value when user data arrives', async () => {
localStorage.setItem('theme', 'light');
const patchSpy = vi.spyOn(api, 'patchJSON');

await renderApp({ signed: true, user: fakeUser('dark') });

await waitFor(() =>
expect(document.body.getAttribute('data-bs-theme')).toBe('dark')
);
expect(localStorage.getItem('theme')).toBe('dark');
expect(patchSpy).not.toHaveBeenCalled();
});

test('Pending save wins over the server value and is retried on user data arrival', async () => {
localStorage.setItem('theme', 'dark');
localStorage.setItem(THEME_PENDING, 'dark');
const patchSpy = vi.spyOn(api, 'patchJSON').mockResolvedValue(undefined);

await renderApp({ signed: true, user: fakeUser('light') });

await waitFor(() =>
expect(patchSpy).toHaveBeenCalledWith(
ApiConfig.userUrl,
expect.objectContaining({ theme: 'dark' })
)
);
expect(document.body.getAttribute('data-bs-theme')).toBe('dark');
await waitFor(() =>
expect(localStorage.getItem(THEME_PENDING)).toBeNull()
);
});
});
Loading