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 { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US"; 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 { 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 GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient"; import client from "../utils/GraphQLClient";
import App from "./App"; import App from "./App";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import getTheme from "./themeProvider"; import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie"; 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 // Base Split configuration
const config = { const config = {
@@ -32,17 +36,47 @@ function SplitClientProvider({ children }) {
return children; return children;
} }
function AppContainer() { const mapDispatchToProps = (dispatch) => ({
const { t } = useTranslation(); setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode))
const [isDarkMode, setIsDarkMode] = useState(true); // Manage dark mode state });
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); // Memoize theme
// 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(() => { useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
return () => document.documentElement.removeAttribute("data-theme"); // Cleanup return () => document.documentElement.removeAttribute("data-theme");
}, [isDarkMode]); }, [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 ( return (
<CookiesProvider> <CookiesProvider>
<ApolloProvider client={client}> <ApolloProvider client={client}>
@@ -60,10 +94,6 @@ function AppContainer() {
<SplitFactoryProvider config={config}> <SplitFactoryProvider config={config}>
<SplitClientProvider> <SplitClientProvider>
<App /> <App />
{/* Optional: Button to toggle dark mode for testing */}
<button onClick={() => setIsDarkMode(!isDarkMode)}>
{t("general.toggleDarkMode")}
</button>
</SplitClientProvider> </SplitClientProvider>
</SplitFactoryProvider> </SplitFactoryProvider>
</ConfigProvider> </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, UsergroupAddOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } 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 { BsKanban } from "react-icons/bs";
import { FiLogOut } from "react-icons/fi"; import { FiLogOut } from "react-icons/fi";
import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi"; import { GiPlayerTime, GiSettingsKnobs } from "react-icons/gi";
@@ -33,6 +33,7 @@ import { RiSurveyLine } from "react-icons/ri";
import { IoBusinessOutline } from "react-icons/io5"; import { IoBusinessOutline } from "react-icons/io5";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx"; import LockWrapper from "../../components/lock-wrapper/lock-wrapper.component.jsx";
import { Tooltip } from "antd";
const buildLeftMenuItems = ({ const buildLeftMenuItems = ({
t, t,
@@ -41,7 +42,9 @@ const buildLeftMenuItems = ({
setTaskUpsertContext, setTaskUpsertContext,
setReportCenterContext, setReportCenterContext,
signOutStart, signOutStart,
accountingChildren accountingChildren,
handleDarkModeToggle,
darkMode
}) => { }) => {
return [ return [
{ {
@@ -331,6 +334,15 @@ const buildLeftMenuItems = ({
label: t("user.actions.signout"), label: t("user.actions.signout"),
onClick: () => signOutStart() 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", key: "help",
id: "header-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 { TASKS_CENTER_POLL_INTERVAL, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { QUERY_MY_TASKS_COUNT } from "../../graphql/tasks.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 { setModalContext } from "../../redux/modals/modals.actions";
import { signOutStart } from "../../redux/user/user.actions"; import { signOutStart } from "../../redux/user/user.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; 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 TaskCenterContainer from "../task-center/task-center.container.jsx";
import buildAccountingChildren from "./buildAccountingChildren.jsx"; import buildAccountingChildren from "./buildAccountingChildren.jsx";
import buildLeftMenuItems from "./buildLeftMenuItems.jsx"; import buildLeftMenuItems from "./buildLeftMenuItems.jsx";
import { toggleDarkMode } from "../../redux/application/application.actions";
// --- Redux mappings --- // --- Redux mappings ---
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
recentItems: selectRecentItems, recentItems: selectRecentItems,
selectedHeader: selectSelectedHeader, selectedHeader: selectSelectedHeader,
bodyshop: selectBodyshop bodyshop: selectBodyshop,
darkMode: selectDarkMode
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
@@ -38,7 +40,8 @@ const mapDispatchToProps = (dispatch) => ({
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })), setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
signOutStart: () => dispatch(signOutStart()), signOutStart: () => dispatch(signOutStart()),
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })), 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 --- // --- Utility Hooks ---
@@ -84,22 +87,22 @@ function useIncompleteTaskCount(assignedToId, bodyshopId, isEmployee, isConnecte
} }
// --- Main Component --- // --- Main Component ---
function Header(props) { function Header({
const { handleMenuClick,
handleMenuClick, currentUser,
currentUser, bodyshop,
bodyshop, selectedHeader,
selectedHeader, signOutStart,
signOutStart, setBillEnterContext,
setBillEnterContext, setTimeTicketContext,
setTimeTicketContext, setPaymentContext,
setPaymentContext, setReportCenterContext,
setReportCenterContext, recentItems,
recentItems, setCardPaymentContext,
setCardPaymentContext, setTaskUpsertContext,
setTaskUpsertContext toggleDarkMode,
} = props; darkMode
}) {
// Feature flags // Feature flags
const { const {
treatments: { ImEXPay, DmsAp, Simple_Inventory } treatments: { ImEXPay, DmsAp, Simple_Inventory }
@@ -216,6 +219,10 @@ function Header(props) {
[handleMenuClick] [handleMenuClick]
); );
const handleDarkModeToggle = useCallback(() => {
toggleDarkMode();
}, [toggleDarkMode]);
// --- Menu Items --- // --- Menu Items ---
// built externally to keep the component clean, but on this level to prevent unnecessary re-renders // built externally to keep the component clean, but on this level to prevent unnecessary re-renders
@@ -257,9 +264,21 @@ function Header(props) {
setTaskUpsertContext, setTaskUpsertContext,
setReportCenterContext, setReportCenterContext,
signOutStart, signOutStart,
accountingChildren accountingChildren,
darkMode,
handleDarkModeToggle
}), }),
[t, bodyshop, recentItems, setTaskUpsertContext, setReportCenterContext, signOutStart, accountingChildren] [
t,
bodyshop,
recentItems,
setTaskUpsertContext,
setReportCenterContext,
signOutStart,
accountingChildren,
darkMode,
handleDarkModeToggle
]
); );
const rightMenuItems = useMemo(() => { const rightMenuItems = useMemo(() => {
@@ -292,6 +311,7 @@ function Header(props) {
), ),
onClick: handleTaskCenterClick onClick: handleTaskCenterClick
}); });
return items; return items;
}, [ }, [
scenarioNotificationsOn, scenarioNotificationsOn,

View File

@@ -77,3 +77,11 @@ export const setWssStatus = (status) => ({
type: ApplicationActionTypes.SET_WSS_STATUS, type: ApplicationActionTypes.SET_WSS_STATUS,
payload: 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, jobReadOnly: false,
partnerVersion: null, partnerVersion: null,
alerts: {} alerts: {},
darkMode: false
}; };
const applicationReducer = (state = INITIAL_STATE, action) => { const applicationReducer = (state = INITIAL_STATE, action) => {
@@ -104,6 +105,16 @@ const applicationReducer = (state = INITIAL_STATE, action) => {
alerts: newAlertsMap alerts: newAlertsMap
}; };
} }
case ApplicationActionTypes.TOGGLE_DARK_MODE:
return {
...state,
darkMode: !state.darkMode
};
case ApplicationActionTypes.SET_DARK_MODE:
return {
...state,
darkMode: action.payload
};
default: default:
return state; return state;
} }

View File

@@ -24,3 +24,4 @@ export const selectProblemJobs = createSelector([selectApplication], (applicatio
export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable); export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable);
export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus); export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus);
export const selectAlerts = createSelector([selectApplication], (application) => application.alerts); 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_PROBLEM_JOBS: "SET_PROBLEM_JOBS",
SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE", SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE",
SET_WSS_STATUS: "SET_WSS_STATUS", 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; export default ApplicationActionTypes;

View File

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

View File

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

View File

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