diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a31a8fc..977558d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: React Template CI on: - pull_request_target: + pull_request: branches: - master @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: @@ -42,5 +42,5 @@ jobs: - name: SonarQube Scan uses: sonarsource/sonarqube-scan-action@master env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/.storybook/preview.js b/.storybook/preview.js index decf3ec3..69c3fe56 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,4 +1,44 @@ +import ReactDOM, { flushSync } from 'react-dom'; +import { createRoot } from 'react-dom/client'; import enMessages from '../app/translations/en.json'; + +// Polyfill for React 18/19 compatibility with Storybook 6.x +// Storybook 6.x uses the deprecated render and unmountComponentAtNode APIs + +// Store roots for cleanup +const rootsMap = new WeakMap(); + +if (!ReactDOM.render) { + ReactDOM.render = (element, container, callback) => { + let root = rootsMap.get(container); + if (!root) { + root = createRoot(container); + rootsMap.set(container, root); + } + flushSync(() => { + root.render(element); + }); + if (callback) { + callback(); + } + return { + unmount: () => root.unmount() + }; + }; +} + +if (!ReactDOM.unmountComponentAtNode) { + ReactDOM.unmountComponentAtNode = (container) => { + const root = rootsMap.get(container); + if (root) { + root.unmount(); + rootsMap.delete(container); + return true; + } + return false; + }; +} + export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { diff --git a/README.md b/README.md index 05f0998f..6a1385a0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,36 @@ An enterprise react template application showcasing - Testing strategies, Global - Go through the other scripts in `package.json` +## Dark Mode Support 🌙 + +- Dark mode toggle with theme persistence using localStorage + + The app supports light and dark themes that can be toggled by the user. The selected theme preference is persisted in localStorage and automatically applied on subsequent visits. + + **Key Features:** + + - Toggle between light and dark themes + - Theme preference saved in localStorage + - Smooth theme transitions + - Automatic theme restoration on app reload + + Take a look at the following files: + + - [app/components/DarkModeToggle/index.js](app/components/DarkModeToggle/index.js) - Toggle button component + - [app/contexts/themeContext.js](app/contexts/themeContext.js) - Theme context with light and dark palettes + - [app/containers/App/index.js](app/containers/App/index.js) - Theme provider implementation and MUI theme integration + + **Usage:** + + ```jsx + import { DarkModeToggle } from '@components/DarkModeToggle'; + + // Place the toggle button anywhere in your app + ; + ``` + + The theme state is managed using React Context (ThemeContext) with localStorage persistence. Material-UI's ThemeProvider receives a dynamically generated theme object that updates when dark mode is toggled, ensuring all MUI components respond to theme changes. + ## Global state management using reduxSauce - Global state management using [Redux Sauce](https://github.com/infinitered/reduxsauce) diff --git a/app/components/DarkModeToggle/index.js b/app/components/DarkModeToggle/index.js new file mode 100644 index 00000000..fd25a00f --- /dev/null +++ b/app/components/DarkModeToggle/index.js @@ -0,0 +1,93 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { IconButton, Tooltip } from '@mui/material'; +import { Brightness4, Brightness7 } from '@mui/icons-material'; +import { useTheme } from '@app/contexts/themeContext'; + +const ToggleButton = styled(IconButton)` + && { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 56px; + height: 56px; + background: ${(props) => props.bgcolor}; + color: ${(props) => props.color}; + box-shadow: 0 4px 20px ${(props) => props.shadowcolor}; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1000; + + &:hover { + background: ${(props) => props.hoverbg}; + transform: translateY(-4px) rotate(15deg); + box-shadow: 0 8px 30px ${(props) => props.shadowcolor}; + } + + &:active { + transform: translateY(-2px) rotate(0deg); + } + + @media (max-width: 768px) { + width: 48px; + height: 48px; + bottom: 1.5rem; + right: 1.5rem; + } + } +`; + +const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + animation: ${(props) => (props.animate ? 'rotate 0.5s ease-in-out' : 'none')}; + + @keyframes rotate { + from { + transform: rotate(0deg) scale(0.8); + opacity: 0.5; + } + to { + transform: rotate(360deg) scale(1); + opacity: 1; + } + } +`; + +// dark mode added +// eslint-disable-next-line complexity +export const DarkModeToggle = () => { + const { isDarkMode, toggleTheme, colors } = useTheme(); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleToggle = () => { + setIsAnimating(true); + toggleTheme(); + setTimeout(() => setIsAnimating(false), 500); + }; + + const tooltipTitle = isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'; + const bgColor = isDarkMode ? colors.surface : colors.primary; + const textColor = isDarkMode ? colors.accent : colors.text; + const hoverBgColor = isDarkMode ? colors.hover : colors.secondary; + const Icon = isDarkMode ? Brightness7 : Brightness4; + + return ( + + + + + + + + ); +}; + +export default DarkModeToggle; diff --git a/app/components/DarkModeToggle/tests/index.test.js b/app/components/DarkModeToggle/tests/index.test.js new file mode 100644 index 00000000..17db72ca --- /dev/null +++ b/app/components/DarkModeToggle/tests/index.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { DarkModeToggle } from '../index'; +import { ThemeProvider } from '@app/contexts/themeContext'; + +describe('DarkModeToggle', () => { + const renderWithTheme = (initialMode = 'light') => { + if (initialMode === 'dark') { + localStorage.setItem('theme', 'dark'); + } else { + localStorage.setItem('theme', 'light'); + } + + return render( + + + + ); + }; + + beforeEach(() => { + localStorage.clear(); + }); + + it('should render the toggle button', () => { + renderWithTheme(); + const button = screen.getByRole('button', { name: /toggle dark mode/i }); + expect(button).toBeInTheDocument(); + }); + + it('should show sun icon in dark mode', () => { + renderWithTheme('dark'); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + }); + + it('should show moon icon in light mode', () => { + renderWithTheme('light'); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + }); + + it('should toggle theme when clicked', () => { + renderWithTheme(); + const button = screen.getByRole('button', { name: /toggle dark mode/i }); + + // Click to switch to dark mode + fireEvent.click(button); + expect(localStorage.getItem('theme')).toBe('dark'); + + // Click to switch back to light mode + fireEvent.click(button); + expect(localStorage.getItem('theme')).toBe('light'); + }); + + it('should have fixed positioning', () => { + renderWithTheme(); + const button = screen.getByRole('button'); + const styles = window.getComputedStyle(button); + expect(styles.position).toBe('fixed'); + }); + + it('should show tooltip on hover', () => { + renderWithTheme(); + const button = screen.getByRole('button'); + fireEvent.mouseOver(button); + // Material-UI tooltips appear after a delay + }); +}); diff --git a/app/components/ProtectedRoute/index.js b/app/components/ProtectedRoute/index.js index 529bf950..4b1dcc8e 100644 --- a/app/components/ProtectedRoute/index.js +++ b/app/components/ProtectedRoute/index.js @@ -12,7 +12,11 @@ import routeConstants from '@utils/routeConstants'; const ProtectedRoute = ({ render: C, isLoggedIn, handleLogout, ...rest }) => { const isUnprotectedRoute = Object.keys(routeConstants) - .filter((key) => !routeConstants[key].isProtected) + .filter((key) => { + // eslint-disable-next-line security/detect-object-injection + return !routeConstants[key].isProtected; + }) + // eslint-disable-next-line security/detect-object-injection .map((key) => routeConstants[key].route) .includes(rest.path) && rest.exact; diff --git a/app/components/ProtectedRoute/tests/__snapshots__/index.test.js.snap b/app/components/ProtectedRoute/tests/__snapshots__/index.test.js.snap index 8f25242d..77f1be60 100644 --- a/app/components/ProtectedRoute/tests/__snapshots__/index.test.js.snap +++ b/app/components/ProtectedRoute/tests/__snapshots__/index.test.js.snap @@ -10,18 +10,23 @@ exports[` should render and match the snapshot 1`] = ` class="css-1cnh6jp e666d1v2" >

Go to Storybook

should render and match the snapshot 1`] = `

should render and match the snapshot 1`] = ` Get details of repositories

should render and match the snapshot 1`] = ` >
props.font()}; } `; -const getFontStyle = (type) => fonts.style[type] || (() => ''); +const getFontStyle = (type) => { + // Safe property access with validation + if (type && Object.prototype.hasOwnProperty.call(fonts.style, type)) { + // eslint-disable-next-line security/detect-object-injection + return fonts.style[type]; + } + return () => ''; +}; const T = ({ type, text, id, marginBottom, values, ...otherProps }) => ( diff --git a/app/containers/App/index.js b/app/containers/App/index.js index 68bcb771..f975e0f7 100644 --- a/app/containers/App/index.js +++ b/app/containers/App/index.js @@ -7,11 +7,9 @@ * */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Switch, Route } from 'react-router-dom'; -import PropTypes from 'prop-types'; import { Router } from 'react-router'; -import map from 'lodash/map'; import { PersistGate } from 'redux-persist/integration/react'; import { Provider } from 'react-redux'; import { CssBaseline, Container } from '@mui/material'; @@ -29,21 +27,9 @@ import { translationMessages } from '@app/i18n'; import history from '@utils/history'; import { SCREEN_BREAK_POINTS } from '@utils/constants'; import configureStore from '@app/configureStore'; -import { colors } from '@themes'; +import { ThemeProvider, useTheme } from '@app/contexts/themeContext'; +import { DarkModeToggle } from '@app/components/DarkModeToggle'; -export const theme = createTheme({ - palette: { - primary: { - main: colors.primary - }, - secondary: { - main: colors.secondary - } - }, - breakpoints: { - values: SCREEN_BREAK_POINTS - } -}); /** * App component that sets up the application with routing, theme, and language support. * It also handles redirect logic based on the query parameters in the URL. @@ -52,15 +38,45 @@ export const theme = createTheme({ * * @returns {JSX.Element} The App component with the application setup. */ -export function App() { +function AppContent() { const [store, setStore] = useState(null); const [persistor, setPersistor] = useState(null); + const { colors: themeColors } = useTheme(); + + // Create MUI theme that responds to dark mode + const muiTheme = useMemo( + () => + createTheme({ + palette: { + primary: { + main: themeColors.primary + }, + secondary: { + main: themeColors.secondary + }, + background: { + default: themeColors.background, + paper: themeColors.surface + }, + text: { + primary: themeColors.text, + secondary: themeColors.textSecondary + } + }, + breakpoints: { + values: SCREEN_BREAK_POINTS + } + }), + [themeColors] + ); const { location } = history; useEffect(() => { if (location.search.includes('?redirect_uri=')) { const routeToReplace = new URLSearchParams(location.search).get('redirect_uri'); - history.replace(routeToReplace); + if (routeToReplace && routeToReplace.startsWith('/') && !routeToReplace.startsWith('//')) { + history.replace(routeToReplace); + } } const { store: s, persistor: p } = configureStore({}, history); setStore(s); @@ -76,25 +92,27 @@ export function App() { - +
} - of={map(Object.keys(routeConfig))} - renderItem={(routeKey, index) => { - const Component = routeConfig[routeKey].component; + of={Object.keys(routeConfig)} + renderItem={(routeKey) => { + // eslint-disable-next-line security/detect-object-injection + const routeConfigItem = routeConfig[routeKey]; + const Component = routeConfigItem.component; return ( { const updatedProps = { ...props, - ...routeConfig[routeKey].props + ...routeConfigItem.props }; return ; }} @@ -103,6 +121,7 @@ export function App() { }} /> + @@ -114,8 +133,17 @@ export function App() { ); } -App.propTypes = { - location: PropTypes.object, - history: PropTypes.object -}; + +/** + * App wrapper component that provides theme context + * @returns {JSX.Element} The App component wrapped in ThemeProvider + */ +export function App() { + return ( + + + + ); +} + export default App; diff --git a/app/containers/App/tests/__snapshots__/index.test.js.snap b/app/containers/App/tests/__snapshots__/index.test.js.snap index 1c4ed5f1..4bdd5321 100644 --- a/app/containers/App/tests/__snapshots__/index.test.js.snap +++ b/app/containers/App/tests/__snapshots__/index.test.js.snap @@ -3,7 +3,7 @@ exports[` container tests should render and match the snapshot 1`] = `
@@ -33,5 +33,34 @@ exports[` container tests should render and match the snapshot 1`] = `
+
`; diff --git a/app/containers/HomeContainer/index.js b/app/containers/HomeContainer/index.js index 5be72f2c..614c0a90 100644 --- a/app/containers/HomeContainer/index.js +++ b/app/containers/HomeContainer/index.js @@ -15,7 +15,7 @@ import T from '@components/T'; import { If } from '@components/If'; import { For } from '@components/For'; import { RepoCard } from '@components/RepoCard'; -import colors from '@app/themes/colors'; +import { useTheme } from '@app/contexts/themeContext'; import { selectLoading, selectReposData, selectReposError, selectRepoName } from './selectors'; import { homeContainerCreators } from './reducer'; import homeContainerSaga from './saga'; @@ -26,13 +26,26 @@ const CustomCard = styled(Card)` margin: 1.25rem 0; padding: 1rem; max-width: ${(props) => props.maxwidth}; + background: ${(props) => props.bgcolor}; color: ${(props) => props.color}; - ${(props) => props.color && `color: ${props.color}`}; + border: 1px solid ${(props) => props.bordercolor}; + box-shadow: 0 4px 12px ${(props) => props.shadowcolor}; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 6px 20px ${(props) => props.shadowcolor}; + transform: translateY(-2px); + } } `; const CustomCardHeader = styled(CardHeader)` && { padding: 0; + + .MuiCardHeader-title { + color: ${(props) => props.titlecolor}; + font-weight: 600; + } } `; const Container = styled.div` @@ -52,11 +65,40 @@ const RightContent = styled.div` const StyledT = styled(T)` && { - color: ${colors.gotoStories}; + color: ${(props) => props.accentcolor}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.05); + } } `; const StyledOutlinedInput = styled(OutlinedInput)` + && { + background: ${(props) => props.inputbg}; + color: ${(props) => props.inputcolor}; + border-radius: 8px; + transition: all 0.3s ease; + + .MuiOutlinedInput-notchedOutline { + border-color: ${(props) => props.bordercolor}; + } + + &:hover .MuiOutlinedInput-notchedOutline { + border-color: ${(props) => props.hovercolor}; + } + + &.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: ${(props) => props.accentcolor}; + } + + input::placeholder { + color: ${(props) => props.placeholdercolor}; + } + } + legend { display: none; } @@ -92,6 +134,8 @@ export function HomeContainer({ loading }) { const history = useHistory(); + const { colors: themeColors } = useTheme(); + useEffect(() => { if (repoName && !reposData?.items?.length) { dispatchGithubRepos(repoName); @@ -119,11 +163,17 @@ export function HomeContainer({ return ( - + - - - + + + searchRepos(repoName)} + sx={{ color: themeColors.text }} > diff --git a/app/containers/HomeContainer/tests/__snapshots__/index.test.js.snap b/app/containers/HomeContainer/tests/__snapshots__/index.test.js.snap index 84c3392a..8de2a81c 100644 --- a/app/containers/HomeContainer/tests/__snapshots__/index.test.js.snap +++ b/app/containers/HomeContainer/tests/__snapshots__/index.test.js.snap @@ -10,18 +10,23 @@ exports[` tests should render and match the snapshot 1`] = ` class="css-1cnh6jp e666d1v2" >

Go to Storybook

tests should render and match the snapshot 1`] = `

tests should render and match the snapshot 1`] = ` Get details of repositories

tests should render and match the snapshot 1`] = ` >
{}, + theme: {} +}); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +export const ThemeProvider = ({ children }) => { + const [isDarkMode, setIsDarkMode] = useState(() => { + // Check localStorage for saved preference + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + return savedTheme === 'dark'; + } + // Check system preference + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + useEffect(() => { + // Save theme preference + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + + // Update document class for global styles + if (isDarkMode) { + document.documentElement.classList.add('dark-mode'); + document.documentElement.classList.remove('light-mode'); + } else { + document.documentElement.classList.add('light-mode'); + document.documentElement.classList.remove('dark-mode'); + } + }, [isDarkMode]); + + const toggleTheme = () => { + setIsDarkMode((prev) => !prev); + }; + + const theme = useMemo( + () => ({ + isDarkMode, + toggleTheme, + colors: isDarkMode + ? { + primary: '#2d3748', + secondary: '#4a5568', + background: '#1a202c', + surface: '#2d3748', + text: '#f7fafc', + textSecondary: '#cbd5e0', + border: '#4a5568', + shadow: 'rgba(0, 0, 0, 0.3)', + hover: '#374151', + accent: '#60a5fa', + success: '#48bb78', + error: '#f56565', + warning: '#ed8936' + } + : { + primary: '#fcedda', + secondary: '#f8c49c', + background: '#ffffff', + surface: '#f8f9fa', + text: '#212529', + textSecondary: '#6c757d', + border: '#e0e0e0', + shadow: 'rgba(0, 0, 0, 0.1)', + hover: '#f1f3f5', + accent: '#1890ff', + success: '#28a745', + error: '#dc3545', + warning: '#ffc107' + } + }), + [isDarkMode] + ); + + return {children}; +}; + +ThemeProvider.propTypes = { + children: PropTypes.node.isRequired +}; + +export default ThemeContext; diff --git a/app/global-styles.js b/app/global-styles.js index 389139b0..4ac13076 100644 --- a/app/global-styles.js +++ b/app/global-styles.js @@ -1,12 +1,17 @@ import { css } from '@emotion/react'; const globalStyles = css` + :root { + --transition-speed: 0.3s; + } + html, body { height: 100vh; width: 100vw; margin: 0; padding: 0; + transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease; } body { @@ -17,10 +22,50 @@ const globalStyles = css` font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; } - #app { - background-color: #fafafa; - min-height: 100%; - min-width: 100%; + /* Light Mode Styles */ + html.light-mode { + #app { + background-color: #ffffff; + color: #212529; + min-height: 100%; + min-width: 100%; + transition: all var(--transition-speed) ease; + } + } + + /* Dark Mode Styles */ + html.dark-mode { + #app { + background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%); + color: #f7fafc; + min-height: 100%; + min-width: 100%; + transition: all var(--transition-speed) ease; + } + + /* Smooth transitions for all interactive elements */ + * { + transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, + border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease; + } + + /* Scrollbar styling for dark mode */ + ::-webkit-scrollbar { + width: 12px; + } + + ::-webkit-scrollbar-track { + background: #1a202c; + } + + ::-webkit-scrollbar-thumb { + background: #4a5568; + border-radius: 6px; + + &:hover { + background: #60a5fa; + } + } } p, @@ -33,6 +78,18 @@ const globalStyles = css` line-height: 1.5em; margin-bottom: 0; } + + /* Smooth animations for theme switch */ + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } `; export default globalStyles; diff --git a/app/i18n.js b/app/i18n.js index 515e2256..77220744 100644 --- a/app/i18n.js +++ b/app/i18n.js @@ -21,8 +21,13 @@ export const formatTranslationMessages = (locale, messages) => { const defaultFormattedMessages = locale !== DEFAULT_LOCALE ? formatTranslationMessages(DEFAULT_LOCALE, enTranslationMessages) : {}; const flattenFormattedMessages = (formattedMessages, key) => { + // key comes from Object.keys(messages), so it's always an own property + // eslint-disable-next-line security/detect-object-injection + const messageValue = messages[key]; + const hasDefaultMessage = Object.prototype.hasOwnProperty.call(defaultFormattedMessages, key); const formattedMessage = - !messages[key] && locale !== DEFAULT_LOCALE ? defaultFormattedMessages[key] : messages[key]; + // eslint-disable-next-line security/detect-object-injection + !messageValue && locale !== DEFAULT_LOCALE && hasDefaultMessage ? defaultFormattedMessages[key] : messageValue; return Object.assign(formattedMessages, { [key]: formattedMessage }); }; return Object.keys(messages).reduce(flattenFormattedMessages, {}); diff --git a/app/themes/colors.js b/app/themes/colors.js index 43ef7995..67fc9ee1 100644 --- a/app/themes/colors.js +++ b/app/themes/colors.js @@ -23,12 +23,34 @@ const colors = { gotoStories, theme: { lightMode: { - primary, - secondary + primary: '#fcedda', + secondary: '#f8c49c', + background: '#ffffff', + surface: '#f8f9fa', + text: '#212529', + textSecondary: '#6c757d', + border: '#e0e0e0', + shadow: 'rgba(0, 0, 0, 0.1)', + hover: '#f1f3f5', + accent: '#1890ff', + success: '#28a745', + error: '#dc3545', + warning: '#ffc107' }, darkMode: { - primary: secondary, - secondary: primary + primary: '#2d3748', + secondary: '#4a5568', + background: '#1a202c', + surface: '#2d3748', + text: '#f7fafc', + textSecondary: '#cbd5e0', + border: '#4a5568', + shadow: 'rgba(0, 0, 0, 0.3)', + hover: '#374151', + accent: '#60a5fa', + success: '#48bb78', + error: '#f56565', + warning: '#ed8936' } } }; diff --git a/app/themes/tests/colors.test.js b/app/themes/tests/colors.test.js index 4310e549..414e55ff 100644 --- a/app/themes/tests/colors.test.js +++ b/app/themes/tests/colors.test.js @@ -3,12 +3,34 @@ import colors from '../colors'; describe('colors', () => { it('should have the correct font-size', () => { expect(colors.theme.lightMode).toEqual({ - primary: colors.primary, - secondary: colors.secondary + primary: '#fcedda', + secondary: '#f8c49c', + background: '#ffffff', + surface: '#f8f9fa', + text: '#212529', + textSecondary: '#6c757d', + border: '#e0e0e0', + shadow: 'rgba(0, 0, 0, 0.1)', + hover: '#f1f3f5', + accent: '#1890ff', + success: '#28a745', + error: '#dc3545', + warning: '#ffc107' }); expect(colors.theme.darkMode).toEqual({ - primary: colors.secondary, - secondary: colors.primary + primary: '#2d3748', + secondary: '#4a5568', + background: '#1a202c', + surface: '#2d3748', + text: '#f7fafc', + textSecondary: '#cbd5e0', + border: '#4a5568', + shadow: 'rgba(0, 0, 0, 0.3)', + hover: '#374151', + accent: '#60a5fa', + success: '#48bb78', + error: '#f56565', + warning: '#ed8936' }); }); }); diff --git a/app/utils/testUtils.js b/app/utils/testUtils.js index bd74410e..95dcd3ea 100644 --- a/app/utils/testUtils.js +++ b/app/utils/testUtils.js @@ -4,14 +4,34 @@ import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { BrowserRouter, Router } from 'react-router-dom'; import { i18n } from '@lingui/core'; -import { ThemeProvider } from '@mui/material/styles'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; import configureStore from '@app/configureStore'; import { DEFAULT_LOCALE, translationMessages } from '@app/i18n'; import ConnectedLanguageProvider from '@containers/LanguageProvider'; -import { theme } from '@containers/App'; +import { colors } from '@themes'; +import { ThemeProvider as CustomThemeProvider } from '@app/contexts/themeContext'; + +// Clean up theme side effects between tests +afterEach(() => { + localStorage.removeItem('theme'); + document.documentElement.classList.remove('dark', 'light'); +}); + +// Create a basic theme for testing +const testTheme = createTheme({ + palette: { + primary: { + main: colors.primary + }, + secondary: { + main: colors.secondary + } + } +}); export const renderWithIntl = (children) => { i18n.loadLocaleData(DEFAULT_LOCALE, { plurals: DEFAULT_LOCALE }); + // eslint-disable-next-line security/detect-object-injection i18n.load(DEFAULT_LOCALE, translationMessages[DEFAULT_LOCALE]); i18n.activate(DEFAULT_LOCALE); return render({children}); @@ -22,9 +42,11 @@ export const renderProvider = (children, history) => { return render( - - {history ? {children} : {children}} - + + + {history ? {children} : {children}} + + ); diff --git a/yarn.lock b/yarn.lock index d977bcc4..a2e0dbea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1880,7 +1880,7 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.1", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.4.3", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.1", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.24.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e" integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw== @@ -4906,7 +4906,7 @@ dependencies: defer-to-connect "^2.0.0" -"@testing-library/dom@^8.3.0", "@testing-library/dom@^8.5.0": +"@testing-library/dom@^8.3.0": version "8.20.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" integrity sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g== @@ -4935,14 +4935,12 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@^13.3.0": - version "13.4.0" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.4.0.tgz#6a31e3bf5951615593ad984e96b9e5e2d9380966" - integrity sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw== +"@testing-library/react@^16.3.0": + version "16.3.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" + integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.5.0" - "@types/react-dom" "^18.0.0" "@testing-library/user-event@^13.2.1": version "13.5.0" @@ -5252,13 +5250,6 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.12.tgz#afa96b383a3a6fdc859453a1892d41b607fc7756" integrity sha512-bZcOkJ6uWrL0Qb2NAWKa7TBU+mJHPzhx9jjLL1KHF+XpzEcR7EXHvjbHlGtR/IsP1vyPrehuS6XqkmaePy//mg== -"@types/react-dom@^18.0.0": - version "18.2.19" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" - integrity sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA== - dependencies: - "@types/react" "*" - "@types/react-transition-group@^4.4.10": version "4.4.10" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac" @@ -5338,6 +5329,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@types/vfile-message@*": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/vfile-message/-/vfile-message-2.0.0.tgz#690e46af0fdfc1f9faae00cd049cc888957927d5" @@ -12366,7 +12362,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -15628,7 +15624,7 @@ lookup-closest-locale@6.0.4: resolved "https://registry.yarnpkg.com/lookup-closest-locale/-/lookup-closest-locale-6.0.4.tgz#1279fed7546a601647bbc980f64423ee990a8590" integrity sha512-bWoFbSGe6f1GvMGzj17LrwMX4FhDXDwZyH04ySVCPbtOJADcSRguZNKewoJ3Ful/MOxD/wRHvFPadk/kYZUbuQ== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -18552,13 +18548,12 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" - integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== +react-dom@^19.1.1: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.0" + scheduler "^0.27.0" react-element-to-jsx-string@^14.3.4: version "14.3.4" @@ -18615,7 +18610,7 @@ react-is@17.0.2, react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.6: +react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -18625,17 +18620,13 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-redux@7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.0.2.tgz#34b280a3482aaf60e7d4a504b1295165cbe6b86a" - integrity sha512-uKRuMgQt8dWbcz0U75oFK5tDo3boyAKrqvf/j94vpqRFFZfyDDy4kofUgloFIGyuKTq2Zz51zgK9RzOTFXk5ew== +react-redux@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5" + integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g== dependencies: - "@babel/runtime" "^7.4.3" - hoist-non-react-statics "^3.3.0" - invariant "^2.2.4" - loose-envify "^1.4.0" - prop-types "^15.7.2" - react-is "^16.8.6" + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" react-refresh@^0.11.0: version "0.11.0" @@ -18690,12 +18681,10 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -react@18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" +react@^19.1.1: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== read-pkg-up@^1.0.1: version "1.0.1" @@ -19606,12 +19595,10 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" - integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== schema-utils@2.7.0: version "2.7.0" @@ -21946,6 +21933,11 @@ url@^0.11.0: punycode "^1.4.1" qs "^6.11.2" +use-sync-external-store@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"