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`] = `
Get details of repositories
should render and match the snapshot 1`] = `
>
should render and match the snapshot 1`] = `