From a3122a59b110907077c7edc162196029b6ece67e Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 13 Aug 2025 18:22:12 -0400 Subject: [PATCH] feature/IO-3255-simplified-parts-management - centralize alerts --- .../global-footer/global-footer.component.jsx | 13 +-- client/src/hooks/useAlertsNotifications.jsx | 96 +++++++++++++++++++ .../pages/manage/manage.page.component.jsx | 76 ++------------- .../simplified-parts.page.component.jsx | 80 ++-------------- 4 files changed, 111 insertions(+), 154 deletions(-) create mode 100644 client/src/hooks/useAlertsNotifications.jsx diff --git a/client/src/components/global-footer/global-footer.component.jsx b/client/src/components/global-footer/global-footer.component.jsx index 68b306943..a66bdb0fd 100644 --- a/client/src/components/global-footer/global-footer.component.jsx +++ b/client/src/components/global-footer/global-footer.component.jsx @@ -6,24 +6,15 @@ import { connect } from "react-redux"; import { Link } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx"; -import { addAlerts } from "../../redux/application/application.actions.js"; -import { selectAlerts, selectIsPartsEntry } from "../../redux/application/application.selectors.js"; -import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors"; +import { selectIsPartsEntry } from "../../redux/application/application.selectors.js"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; const { Footer } = Layout; const mapStateToProps = createStructuredSelector({ - conflict: selectInstanceConflict, - bodyshop: selectBodyshop, - alerts: selectAlerts, isPartsEntry: selectIsPartsEntry }); -const mapDispatchToProps = (dispatch) => ({ - setAlerts: (alerts) => dispatch(addAlerts(alerts)) -}); - export function GlobalFooter({ isPartsEntry }) { const { t } = useTranslation(); @@ -101,4 +92,4 @@ export function GlobalFooter({ isPartsEntry }) { ); } -export default connect(mapStateToProps, mapDispatchToProps)(GlobalFooter); +export default connect(mapStateToProps)(GlobalFooter); diff --git a/client/src/hooks/useAlertsNotifications.jsx b/client/src/hooks/useAlertsNotifications.jsx new file mode 100644 index 000000000..622a23aff --- /dev/null +++ b/client/src/hooks/useAlertsNotifications.jsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import * as Sentry from "@sentry/react"; +import { useNotification } from "../contexts/Notifications/notificationContext.jsx"; +import { addAlerts } from "../redux/application/application.actions.js"; +import { selectAlerts } from "../redux/application/application.selectors.js"; +import InstanceRenderManager from "../utils/instanceRenderMgr.js"; + +const DEFAULT_ALERT_FILE_URL = InstanceRenderManager({ + imex: "https://images.imex.online/alerts/alerts-imex.json", + rome: "https://images.imex.online/alerts/alerts-rome.json" +}); + +/** + * useAlertsNotifications + * - Fetches alerts JSON and stores it in Redux + * - Shows notifications for new alerts (deduped via localStorage) + * - Persists dismissed alert IDs under a stable key (default: "displayedAlerts") + */ +export default function useAlertsNotifications({ + storageKey = "displayedAlerts", + alertFileUrl = DEFAULT_ALERT_FILE_URL +} = {}) { + const dispatch = useDispatch(); + const alerts = useSelector(selectAlerts); + const notification = useNotification(); + + // Track which alerts have been shown/dismissed + const [displayedAlertIds, setDisplayedAlertIds] = useState([]); + + // Load displayed alert IDs from localStorage on mount + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey) || "[]"; + const parsed = JSON.parse(raw); + setDisplayedAlertIds(Array.isArray(parsed) ? parsed : []); + } catch { + setDisplayedAlertIds([]); + } + }, [storageKey]); + + // Fetch alerts from the remote JSON and dispatch to Redux + useEffect(() => { + let aborted = false; + + async function fetchAlerts() { + try { + const response = await fetch(alertFileUrl, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const fetchedAlerts = await response.json(); + if (!aborted) dispatch(addAlerts(fetchedAlerts)); + } catch (error) { + Sentry.captureException(error, { + tags: { scope: "useAlertsNotifications", phase: "fetch-alerts" } + }); + } + } + + fetchAlerts(); + return () => { + aborted = true; + }; + }, [alertFileUrl, dispatch]); + + // Show notifications for any alerts that haven't been dismissed yet + useEffect(() => { + if (!alerts || Object.keys(alerts).length === 0) return; + + const alertArray = Object.values(alerts).filter(Boolean); + const newAlerts = alertArray.filter((a) => !displayedAlertIds.includes(a.id)); + + newAlerts.forEach((alert) => { + notification.open({ + key: `notification-alerts-${alert.id}`, + message: alert.message, + description: alert.description, + type: alert.type || "info", + duration: 0, + closable: true, + onClose: () => { + setDisplayedAlertIds((prev) => { + const next = prev.includes(alert.id) ? prev : [...prev, alert.id]; + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + } catch (e) { + Sentry.captureException(e, { + tags: { scope: "useAlertsNotifications", phase: "persist-dismissed" } + }); + } + return next; + }); + } + }); + }); + }, [alerts, displayedAlertIds, notification, storageKey]); +} diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index 735f93161..339667694 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -20,11 +20,9 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com import PartnerPingComponent from "../../components/partner-ping/partner-ping.component"; import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component"; import UpdateAlert from "../../components/update-alert/update-alert.component"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; -import { addAlerts } from "../../redux/application/application.actions.js"; -import { selectAlerts } from "../../redux/application/application.selectors.js"; import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; +import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx"; const PrintCenterModalContainer = lazy( () => import("../../components/print-center-modal/print-center-modal.container") @@ -108,78 +106,16 @@ const { Content } = Layout; const mapStateToProps = createStructuredSelector({ conflict: selectInstanceConflict, - bodyshop: selectBodyshop, - alerts: selectAlerts + bodyshop: selectBodyshop }); -const ALERT_FILE_URL = InstanceRenderManager({ - imex: "https://images.imex.online/alerts/alerts-imex.json", - rome: "https://images.imex.online/alerts/alerts-rome.json" -}); - -const mapDispatchToProps = (dispatch) => ({ - setAlerts: (alerts) => dispatch(addAlerts(alerts)) -}); - -export function Manage({ conflict, bodyshop, alerts, setAlerts }) { +export function Manage({ conflict, bodyshop }) { const { t } = useTranslation(); const [chatVisible] = useState(false); - const notification = useNotification(); - // State to track displayed alerts - const [displayedAlertIds, setDisplayedAlertIds] = useState([]); + // Centralized alerts handling (fetch + dedupe + notifications) + useAlertsNotifications(); - // Fetch displayed alerts from localStorage on mount - useEffect(() => { - const displayedAlerts = JSON.parse(localStorage.getItem("displayedAlerts") || "[]"); - setDisplayedAlertIds(displayedAlerts); - }, []); - - // Fetch alerts from the JSON file and dispatch to Redux store - useEffect(() => { - const fetchAlerts = async () => { - try { - const response = await fetch(ALERT_FILE_URL); - const fetchedAlerts = await response.json(); - setAlerts(fetchedAlerts); - } catch (error) { - console.warn("Error fetching alerts:", error.message); - } - }; - - fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`); - }, [setAlerts]); - - // Use useEffect to watch for new alerts - useEffect(() => { - if (alerts && Object.keys(alerts).length > 0) { - // Convert the alerts object into an array - const alertArray = Object.values(alerts); - - // Filter out alerts that have already been dismissed - const newAlerts = alertArray.filter((alert) => !displayedAlertIds.includes(alert.id)); - - newAlerts.forEach((alert) => { - // Display the notification - notification.open({ - key: "notification-alerts-" + alert.id, - message: alert.message, - description: alert.description, - type: alert.type || "info", - duration: 0, - closable: true, - onClose: () => { - // When the notification is closed, update displayed alerts state and localStorage - setDisplayedAlertIds((prevIds) => { - const updatedIds = [...prevIds, alert.id]; - localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds)); - return updatedIds; - }); - } - }); - }); - } - }, [alerts, displayedAlertIds, notification]); useEffect(() => { window.Canny("initChangelog", { appID: "680bd2c7ee501290377f6686", @@ -657,4 +593,4 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) { ); } -export default connect(mapStateToProps, mapDispatchToProps)(Manage); +export default connect(mapStateToProps)(Manage); diff --git a/client/src/pages/simplified-parts/simplified-parts.page.component.jsx b/client/src/pages/simplified-parts/simplified-parts.page.component.jsx index 1f31814ad..87a5ba956 100644 --- a/client/src/pages/simplified-parts/simplified-parts.page.component.jsx +++ b/client/src/pages/simplified-parts/simplified-parts.page.component.jsx @@ -1,6 +1,6 @@ import * as Sentry from "@sentry/react"; import { FloatButton, Layout, Spin } from "antd"; -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { Route, Routes } from "react-router-dom"; @@ -12,11 +12,9 @@ import GlobalFooter from "../../components/global-footer/global-footer.component import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx"; import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component.jsx"; import UpdateAlert from "../../components/update-alert/update-alert.component.jsx"; -import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; -import { addAlerts } from "../../redux/application/application.actions.js"; -import { selectAlerts } from "../../redux/application/application.selectors.js"; import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors.js"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; +import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx"; const SimplifiedPartsJobsPage = lazy(() => import("../simplified-parts-jobs/simplified-parts-jobs.page.jsx")); const SimplifiedPartsJobsDetailPage = lazy( @@ -35,77 +33,14 @@ const { Content } = Layout; const mapStateToProps = createStructuredSelector({ conflict: selectInstanceConflict, - bodyshop: selectBodyshop, - alerts: selectAlerts + bodyshop: selectBodyshop }); -const ALERT_FILE_URL = InstanceRenderManager({ - imex: "https://images.imex.online/alerts/alerts-imex.json", - rome: "https://images.imex.online/alerts/alerts-rome.json" -}); - -const mapDispatchToProps = (dispatch) => ({ - setAlerts: (alerts) => dispatch(addAlerts(alerts)) -}); - -export function SimplifiedPartsPage({ conflict, bodyshop, alerts, setAlerts }) { +export function SimplifiedPartsPage({ conflict, bodyshop }) { const { t } = useTranslation(); - const notification = useNotification(); - // State to track displayed alerts - const [displayedAlertIds, setDisplayedAlertIds] = useState([]); - - // Fetch displayed alerts from localStorage on mount - useEffect(() => { - const displayedAlerts = JSON.parse(localStorage.getItem("displayedAlerts") || "[]"); - setDisplayedAlertIds(displayedAlerts); - }, []); - - // Fetch alerts from the JSON file and dispatch to Redux store - useEffect(() => { - const fetchAlerts = async () => { - try { - const response = await fetch(ALERT_FILE_URL); - const fetchedAlerts = await response.json(); - setAlerts(fetchedAlerts); - } catch (error) { - console.warn("Error fetching alerts:", error.message); - } - }; - - fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`); - }, [setAlerts]); - - // Use useEffect to watch for new alerts - useEffect(() => { - if (alerts && Object.keys(alerts).length > 0) { - // Convert the alerts object into an array - const alertArray = Object.values(alerts); - - // Filter out alerts that have already been dismissed - const newAlerts = alertArray.filter((alert) => !displayedAlertIds.includes(alert.id)); - - newAlerts.forEach((alert) => { - // Display the notification - notification.open({ - key: "notification-alerts-" + alert.id, - message: alert.message, - description: alert.description, - type: alert.type || "info", - duration: 0, - closable: true, - onClose: () => { - // When the notification is closed, update displayed alerts state and localStorage - setDisplayedAlertIds((prevIds) => { - const updatedIds = [...prevIds, alert.id]; - localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds)); - return updatedIds; - }); - } - }); - }); - } - }, [alerts, displayedAlertIds, notification]); + // Centralized alerts handling (fetch + dedupe + notifications) + useAlertsNotifications(); useEffect(() => { document.title = InstanceRenderManager({ @@ -193,7 +128,6 @@ export function SimplifiedPartsPage({ conflict, bodyshop, alerts, setAlerts }) { return ( - {/* */} } showDialog> {PageContent} @@ -205,4 +139,4 @@ export function SimplifiedPartsPage({ conflict, bodyshop, alerts, setAlerts }) { ); } -export default connect(mapStateToProps, mapDispatchToProps)(SimplifiedPartsPage); +export default connect(mapStateToProps)(SimplifiedPartsPage);