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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: React Template CI
on:
pull_request_target:
pull_request:
branches:
- master

Expand All @@ -14,7 +14,7 @@ jobs:

steps:
- uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -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 }}
40 changes: 40 additions & 0 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -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()
};
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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: {
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<DarkModeToggle />;
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
Expand Down
93 changes: 93 additions & 0 deletions app/components/DarkModeToggle/index.js
Original file line number Diff line number Diff line change
@@ -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);
};
Comment on lines +63 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using setTimeout with a hardcoded duration to reset the animation state is fragile. If the animation duration in the CSS (0.5s) is changed, this setTimeout duration (500ms) will also need to be updated manually. A more robust approach is to use the onAnimationEnd event on the animated element to reset the state.

You can update handleToggle as suggested, and then add the onAnimationEnd handler to your IconWrapper to reset the animation state:

<IconWrapper animate={isAnimating} onAnimationEnd={() => setIsAnimating(false)}>
  <Icon fontSize="large" />
</IconWrapper>
  const handleToggle = () => {
    setIsAnimating(true);
    toggleTheme();
  };

Comment on lines +63 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear the animation timeout on unmount to avoid setState after unmount.

If the component unmounts within 500ms, the timeout callback will still fire.

🧹 Suggested cleanup
 export const DarkModeToggle = () => {
   const { isDarkMode, toggleTheme, colors } = useTheme();
   const [isAnimating, setIsAnimating] = React.useState(false);
+  const timeoutRef = React.useRef();

+  React.useEffect(
+    () => () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    },
+    []
+  );

   const handleToggle = () => {
     setIsAnimating(true);
     toggleTheme();
-    setTimeout(() => setIsAnimating(false), 500);
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsAnimating(false), 500);
   };
🤖 Prompt for AI Agents
In `@app/components/DarkModeToggle/index.js` around lines 62 - 66, The timeout
started in handleToggle (setTimeout(() => setIsAnimating(false), 500)) can call
setState after the component unmounts; store the timer id (e.g., using a ref
like animationTimerRef) when you call setTimeout, and clear it in a useEffect
cleanup (and before setting a new timeout when handleToggle runs again) using
clearTimeout(animationTimerRef.current) to avoid calling setIsAnimating after
unmount; update handleToggle to save the returned id and add a useEffect that
returns a cleanup which clears the timer.


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 (
<Tooltip title={tooltipTitle} placement="left" arrow>
<ToggleButton
onClick={handleToggle}
aria-label="toggle dark mode"
bgcolor={bgColor}
color={textColor}
hoverbg={hoverBgColor}
shadowcolor={colors.shadow}
>
<IconWrapper animate={isAnimating}>
<Icon fontSize="large" />
</IconWrapper>
</ToggleButton>
</Tooltip>
);
};

export default DarkModeToggle;
70 changes: 70 additions & 0 deletions app/components/DarkModeToggle/tests/index.test.js
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider>
<DarkModeToggle />
</ThemeProvider>
);
};

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();
});
Comment on lines +32 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These tests only check for the presence of the button, not the specific icon for each theme mode. To make these tests more meaningful, you should assert that the correct icon is rendered for each mode. Material-UI icons have a data-testid you can use for this.

Suggested change
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 show sun icon in dark mode', () => {
renderWithTheme('dark');
expect(screen.getByTestId('Brightness7Icon')).toBeInTheDocument();
expect(screen.queryByTestId('Brightness4Icon')).not.toBeInTheDocument();
});
it('should show moon icon in light mode', () => {
renderWithTheme('light');
expect(screen.getByTestId('Brightness4Icon')).toBeInTheDocument();
expect(screen.queryByTestId('Brightness7Icon')).not.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');
});
Comment on lines +44 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the theme-toggle assertion resilient to effect timing.

localStorage is updated in a useEffect, which may not flush synchronously. Use waitFor to avoid flakiness.

✅ Suggested fix
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
 ...
-  it('should toggle theme when clicked', () => {
+  it('should toggle theme when clicked', async () => {
     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');
+    await waitFor(() => expect(localStorage.getItem('theme')).toBe('dark'));

     // Click to switch back to light mode
     fireEvent.click(button);
-    expect(localStorage.getItem('theme')).toBe('light');
+    await waitFor(() => expect(localStorage.getItem('theme')).toBe('light'));
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 toggle theme when clicked', async () => {
renderWithTheme();
const button = screen.getByRole('button', { name: /toggle dark mode/i });
// Click to switch to dark mode
fireEvent.click(button);
await waitFor(() => expect(localStorage.getItem('theme')).toBe('dark'));
// Click to switch back to light mode
fireEvent.click(button);
await waitFor(() => expect(localStorage.getItem('theme')).toBe('light'));
});
🤖 Prompt for AI Agents
In `@app/components/DarkModeToggle/tests/index.test.js` around lines 44 - 55, The
test's assertions read localStorage synchronously but the component writes it
inside a useEffect, causing flakiness; update the test in index.test.js (the
"should toggle theme when clicked" spec that uses renderWithTheme and the button
from getByRole) to wrap the post-click localStorage assertions in async waitFor
calls (await waitFor(() => { expect(localStorage.getItem('theme')).toBe('dark')
}) and similarly for 'light') so the test waits for the effect to run after
fireEvent.click.


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
});
Comment on lines +64 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test for the tooltip is incomplete. Since Material-UI tooltips appear asynchronously after a delay, you need to use an async test with await and a findBy* query to correctly test for the tooltip's appearance.

  it('should show tooltip on hover', async () => {
    renderWithTheme();
    const button = screen.getByRole('button');
    fireEvent.mouseOver(button);
    const tooltip = await screen.findByRole('tooltip');
    expect(tooltip).toBeInTheDocument();
    expect(tooltip).toHaveTextContent('Switch to Dark Mode');
  });

Comment on lines +64 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The tooltip test has no assertion.

It currently can pass even if the tooltip is broken. Add an assertion (or remove the test).

🧪 Suggested assertion
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
 ...
   it('should show tooltip on hover', () => {
+    jest.useFakeTimers();
     renderWithTheme();
     const button = screen.getByRole('button');
     fireEvent.mouseOver(button);
-    // Material-UI tooltips appear after a delay
+    act(() => {
+      jest.runAllTimers();
+    });
+    expect(screen.getByRole('tooltip')).toHaveTextContent(/switch to dark mode/i);
+    jest.useRealTimers();
   });
🤖 Prompt for AI Agents
In `@app/components/DarkModeToggle/tests/index.test.js` around lines 64 - 69, The
test "should show tooltip on hover" currently has no assertion; after calling
renderWithTheme() and fireEvent.mouseOver(button) (where button is obtained via
screen.getByRole('button')), make the test async and await an assertion that the
tooltip appears — e.g. await screen.findByRole('tooltip') or await
screen.findByText('<expected tooltip text>') — to assert the Material-UI tooltip
is present in the DOM; update the test function signature to async and add the
awaited assertion after fireEvent.mouseOver(button).

});
6 changes: 5 additions & 1 deletion app/components/ProtectedRoute/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
const ProtectedRoute = ({ render: C, isLoggedIn, handleLogout, ...rest }) => {
const isUnprotectedRoute =
Object.keys(routeConstants)
.filter((key) => !routeConstants[key].isProtected)
.filter((key) => {

Check warning on line 15 in app/components/ProtectedRoute/index.js

View workflow job for this annotation

GitHub Actions / Build & Test (21.6.2)

Generic Object Injection Sink
// eslint-disable-next-line security/detect-object-injection

Check warning on line 16 in app/components/ProtectedRoute/index.js

View workflow job for this annotation

GitHub Actions / Build & Test (21.6.2)

Generic Object Injection Sink
return !routeConstants[key].isProtected;
})
// eslint-disable-next-line security/detect-object-injection
.map((key) => routeConstants[key].route)
.includes(rest.path) && rest.exact;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@ exports[`<ProtectedRoute /> should render and match the snapshot 1`] = `
class="css-1cnh6jp e666d1v2"
>
<p
class="e666d1v1 css-16gj8bq egtqi0h0"
class="e666d1v1 css-19wkspa egtqi0h0"
data-testid="redirect"
>
Go to Storybook
</p>
</div>
<div
class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 MuiCard-root e666d1v5 css-19cof65-MuiPaper-root-MuiCard-root"
bgcolor="#2d3748"
bordercolor="#4a5568"
class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 MuiCard-root e666d1v5 css-16z7z58-MuiPaper-root-MuiCard-root"
color="#f7fafc"
maxwidth="500"
shadowcolor="rgba(0, 0, 0, 0.3)"
>
<div
class="MuiCardHeader-root e666d1v4 css-1e709b4-MuiCardHeader-root"
class="MuiCardHeader-root e666d1v4 css-g7q57-MuiCardHeader-root"
titlecolor="#f7fafc"
>
<div
class="MuiCardHeader-content css-1qbkelo-MuiCardHeader-content"
Expand All @@ -34,7 +39,7 @@ exports[`<ProtectedRoute /> should render and match the snapshot 1`] = `
</div>
</div>
<hr
class="MuiDivider-root MuiDivider-fullWidth MuiDivider-light css-b2j1b7-MuiDivider-root"
class="MuiDivider-root MuiDivider-fullWidth css-1q34inh-MuiDivider-root"
/>
<p
class="css-1u0bpvi egtqi0h0"
Expand All @@ -43,7 +48,13 @@ exports[`<ProtectedRoute /> should render and match the snapshot 1`] = `
Get details of repositories
</p>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-colorPrimary MuiInputBase-fullWidth MuiInputBase-adornedEnd e666d1v0 css-16qisbh-MuiInputBase-root-MuiOutlinedInput-root"
accentcolor="#60a5fa"
bordercolor="#4a5568"
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-colorPrimary MuiInputBase-fullWidth MuiInputBase-adornedEnd e666d1v0 css-1mzc5ly-MuiInputBase-root-MuiOutlinedInput-root"
hovercolor="#60a5fa"
inputbg="#1a202c"
inputcolor="#f7fafc"
placeholdercolor="#cbd5e0"
>
<input
class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd css-nxo287-MuiInputBase-input-MuiOutlinedInput-input"
Expand All @@ -56,7 +67,7 @@ exports[`<ProtectedRoute /> should render and match the snapshot 1`] = `
>
<button
aria-label="search repos"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeMedium css-78trlr-MuiButtonBase-root-MuiIconButton-root"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeMedium css-103yn2t-MuiButtonBase-root-MuiIconButton-root"
data-testid="search-icon"
tabindex="0"
type="button"
Expand Down Expand Up @@ -94,11 +105,11 @@ exports[`<ProtectedRoute /> should render and match the snapshot 1`] = `
</div>
</div>
<div
class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 MuiCard-root e666d1v5 css-18bnhwu-MuiPaper-root-MuiCard-root"
class="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 MuiCard-root e666d1v5 css-1vk2b77-MuiPaper-root-MuiCard-root"
color="grey"
>
<div
class="MuiCardHeader-root e666d1v4 css-1e709b4-MuiCardHeader-root"
class="MuiCardHeader-root e666d1v4 css-17coiuq-MuiCardHeader-root"
>
<div
class="MuiCardHeader-content css-1qbkelo-MuiCardHeader-content"
Expand Down
9 changes: 8 additions & 1 deletion app/components/T/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
${(props) => props.font()};
}
`;
const getFontStyle = (type) => fonts.style[type] || (() => '');
const getFontStyle = (type) => {

Check warning on line 20 in app/components/T/index.js

View workflow job for this annotation

GitHub Actions / Build & Test (21.6.2)

Generic Object Injection Sink
// 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 }) => (
<StyledText data-testid="t" font={getFontStyle(type)} marginBottom={marginBottom} {...otherProps}>
Expand Down
Loading
Loading