feature/IO-1113-Online-Dark-Mode - Toggle / Local storage solution

This commit is contained in:
Dave Richer
2025-08-08 11:53:51 -04:00
parent c9572d2db5
commit ec6c0279de
10 changed files with 131 additions and 41 deletions

View File

@@ -2,15 +2,19 @@ import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { connect, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { selectDarkMode } from "../redux/application/application.selectors";
import { setDarkMode } from "../redux/application/application.actions";
// Base Split configuration
const config = {
@@ -32,17 +36,47 @@ function SplitClientProvider({ children }) {
return children;
}
function AppContainer() {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(true); // Manage dark mode state
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); // Memoize theme
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode))
});
// Set data-theme attribute on document root
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
function AppContainer({ currentUser, setDarkMode }) {
const { t } = useTranslation();
const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
// Update data-theme attribute when dark mode changes
useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
return () => document.documentElement.removeAttribute("data-theme"); // Cleanup
return () => document.documentElement.removeAttribute("data-theme");
}, [isDarkMode]);
// Sync Redux darkMode with localStorage on user change
useEffect(() => {
if (currentUser?.uid) {
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) {
setDarkMode(JSON.parse(savedMode));
} else {
setDarkMode(false); // default to light mode
}
} else {
setDarkMode(false);
}
// eslint-disable-next-line
}, [currentUser?.uid]);
// Persist darkMode to localStorage when it or user changes
useEffect(() => {
if (currentUser?.uid) {
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode));
}
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
@@ -60,10 +94,6 @@ function AppContainer() {
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
{/* Optional: Button to toggle dark mode for testing */}
<button onClick={() => setIsDarkMode(!isDarkMode)}>
{t("general.toggleDarkMode")}
</button>
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
@@ -72,4 +102,4 @@ function AppContainer() {
);
}
export default Sentry.withProfiler(AppContainer);
export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer));

View File

@@ -25,7 +25,7 @@ import {
UsergroupAddOutlined,
UserOutlined
} from "@ant-design/icons";
import { FaCalendarAlt, FaCarCrash, FaTasks } from "react-icons/fa";
import { FaCalendarAlt, FaCarCrash, FaMoon, FaSun, FaTasks } from "react-icons/fa";
import { BsKanban } from "react-icons/bs";
import { FiLogOut } from "react-icons/fi";
import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
@@ -33,6 +33,7 @@ import { RiSurveyLine } from "react-icons/ri";
import { IoBusinessOutline } from "react-icons/io5";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
import { Tooltip } from "antd";
const buildLeftMenuItems = ({
t,
@@ -41,7 +42,9 @@ const buildLeftMenuItems = ({
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren
accountingChildren,
handleDarkModeToggle,
darkMode
}) => {
return [
{
@@ -331,6 +334,15 @@ const buildLeftMenuItems = ({
label: t("user.actions.signout"),
onClick: () => signOutStart()
},
{
key: "darkmode-toggle",
id: "header-darkmode-toggle",
label: darkMode ? t("user.actions.light_theme") : t("user.actions.dark_theme"),
icon: (
<Tooltip title={darkMode ? t("Light mode") : t("Dark mode")}>{darkMode ? <FaSun /> : <FaMoon />}</Tooltip>
),
onClick: handleDarkModeToggle
},
{
key: "help",
id: "header-help",

View File

@@ -12,7 +12,7 @@ import { createStructuredSelector } from "reselect";
import { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { selectDarkMode, selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
@@ -22,13 +22,15 @@ import NotificationCenterContainer from "../notification-center/notification-cen
import TaskCenterContainer from "../task-center/task-center.container.jsx";
import buildAccountingChildren from "./buildAccountingChildren.jsx";
import buildLeftMenuItems from "./buildLeftMenuItems.jsx";
import { toggleDarkMode } from "../../redux/application/application.actions";
// --- Redux mappings ---
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
recentItems: selectRecentItems,
selectedHeader: selectSelectedHeader,
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
darkMode: selectDarkMode
});
const mapDispatchToProps = (dispatch) => ({
@@ -38,7 +40,8 @@ const mapDispatchToProps = (dispatch) => ({
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
signOutStart: () => dispatch(signOutStart()),
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" })),
toggleDarkMode: () => dispatch(toggleDarkMode())
});
// --- Utility Hooks ---
@@ -84,22 +87,22 @@ function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnecte
}
// --- Main Component ---
function Header(props) {
const {
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext
} = props;
function Header({
handleMenuClick,
currentUser,
bodyshop,
selectedHeader,
signOutStart,
setBillEnterContext,
setTimeTicketContext,
setPaymentContext,
setReportCenterContext,
recentItems,
setCardPaymentContext,
setTaskUpsertContext,
toggleDarkMode,
darkMode
}) {
// Feature flags
const {
treatments: { ImEXPay, DmsAp, Simple_Inventory }
@@ -216,6 +219,10 @@ function Header(props) {
[handleMenuClick]
);
const handleDarkModeToggle = useCallback(() => {
toggleDarkMode();
}, [toggleDarkMode]);
// --- Menu Items ---
// built externally to keep the component clean, but on this level to prevent unnecessary re-renders
@@ -257,9 +264,21 @@ function Header(props) {
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren
accountingChildren,
darkMode,
handleDarkModeToggle
}),
[t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren]
[
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren,
darkMode,
handleDarkModeToggle
]
);
const rightMenuItems = useMemo(() => {
@@ -292,6 +311,7 @@ function Header(props) {
),
onClick: handleTaskCenterClick
});
return items;
}, [
scenarioNotificationsOn,

View File

@@ -77,3 +77,11 @@ export const setWssStatus = (status) => ({
type: ApplicationActionTypes.SET_WSS_STATUS,
payload: status
});
export const toggleDarkMode = () => ({
type: ApplicationActionTypes.TOGGLE_DARK_MODE
});
export const setDarkMode = (value) => ({
type: ApplicationActionTypes.SET_DARK_MODE,
payload: value
});

View File

@@ -16,7 +16,8 @@ const INITIAL_STATE = {
},
jobReadOnly: false,
partnerVersion: null,
alerts: {}
alerts: {},
darkMode: false
};
const applicationReducer = (state = INITIAL_STATE, action) => {
@@ -104,6 +105,16 @@ const applicationReducer = (state = INITIAL_STATE, action) => {
alerts: newAlertsMap
};
}
case ApplicationActionTypes.TOGGLE_DARK_MODE:
return {
...state,
darkMode: !state.darkMode
};
case ApplicationActionTypes.SET_DARK_MODE:
return {
...state,
darkMode: action.payload
};
default:
return state;
}

View File

@@ -24,3 +24,4 @@ export const selectProblemJobs = createSelector([selectApplication], (applicatio
export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable);
export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus);
export const selectAlerts = createSelector([selectApplication], (application) => application.alerts);
export const selectDarkMode = createSelector([selectApplication], (application) => application.darkMode);

View File

@@ -14,6 +14,8 @@ const ApplicationActionTypes = {
SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS",
SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE",
SET_WSS_STATUS: "SET_WSS_STATUS",
ADD_ALERTS: "ADD_ALERTS"
ADD_ALERTS: "ADD_ALERTS",
TOGGLE_DARK_MODE: "TOGGLE_DARK_MODE",
SET_DARK_MODE: "SET_DARK_MODE"
};
export default ApplicationActionTypes;

View File

@@ -3781,7 +3781,9 @@
"actions": {
"changepassword": "Change Password",
"signout": "Sign Out",
"updateprofile": "Update Profile"
"updateprofile": "Update Profile",
"light_theme": "Switch to Light Theme",
"dark_theme": "Switch to Dark Theme"
},
"errors": {
"updating": "Error updating user or association {{message}}"

View File

@@ -3781,7 +3781,9 @@
"actions": {
"changepassword": "",
"signout": "desconectar",
"updateprofile": "Actualización del perfil"
"updateprofile": "Actualización del perfil",
"light_theme": "",
"dark_theme": ""
},
"errors": {
"updating": ""

View File

@@ -3781,7 +3781,9 @@
"actions": {
"changepassword": "",
"signout": "Déconnexion",
"updateprofile": "Mettre à jour le profil"
"updateprofile": "Mettre à jour le profil",
"light_theme": "",
"dark_theme": ""
},
"errors": {
"updating": ""