feature/IO-3096-GlobalNotifications - Checkpoint

This commit is contained in:
Dave Richer
2025-02-25 17:23:35 -05:00
parent 08b7f0e59c
commit c5d00f7641
9 changed files with 510 additions and 566 deletions

View File

@@ -1,7 +1,7 @@
import { useSplitClient } from "@splitsoftware/splitio-react"; import { useSplitClient } from "@splitsoftware/splitio-react";
import { Button, Result } from "antd"; import { Button, Result } from "antd";
import LogRocket from "logrocket"; import LogRocket from "logrocket";
import React, { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from "react-router-dom";
@@ -23,8 +23,6 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx"; import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx"; import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx"; import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import { useSubscription, useApolloClient, gql } from "@apollo/client";
import { SUBSCRIBE_TO_NOTIFICATIONS } from "../graphql/notifications.queries.js";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component")); const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -48,7 +46,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
const client = useSplitClient().client; const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false); const [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const apolloClient = useApolloClient();
useEffect(() => { useEffect(() => {
if (!navigator.onLine) { if (!navigator.onLine) {
@@ -107,114 +104,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
} }
}, [bodyshop, client, currentUser.authorized]); }, [bodyshop, client, currentUser.authorized]);
// Add subscription for all unread notifications with proper normalization and format
useSubscription(SUBSCRIBE_TO_NOTIFICATIONS, {
onData: ({ data }) => {
if (data.data?.notifications) {
console.log("Subscription data received (all unread):", data.data.notifications);
const newNotifs = data.data.notifications.filter(
(newNotif) =>
!apolloClient.cache
.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
id
__typename
}
}
`
})
?.notifications.some((n) => n.id === newNotif.id)
);
if (newNotifs.length === 0) return;
// Use writeQuery to normalize and add new notifications as References, matching cache format
apolloClient.cache.writeQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`,
data: {
notifications: [
...newNotifs.map((notif) => ({
...notif,
__typename: "notifications",
scenario_text: JSON.stringify(
typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []
),
scenario_meta: JSON.stringify(
typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || []
)
})),
...(apolloClient.cache.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`
})?.notifications || [])
].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc
}
});
// Update notifications_aggregate for unread count
apolloClient.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const unreadCount = existing.aggregate.count + newNotifs.length;
console.log("Updating unread count from subscription:", unreadCount);
return {
...existing,
aggregate: {
...existing.aggregate,
count: unreadCount
}
};
}
},
optimistic: false
});
}
},
onError: (err) => {
console.error("Subscription error:", err);
// Fallback: Poll for all unread notifications if subscription fails
apolloClient
.query({
query: SUBSCRIBE_TO_NOTIFICATIONS,
variables: { where: { read: { _is_null: true } }, order_by: { created_at: "desc" } },
pollInterval: 30000
})
.catch((pollError) => console.error("Polling error:", pollError));
},
shouldResubscribe: true,
skip: !currentUser.authorized // Skip if user isnt authorized
});
if (currentUser.authorized === null) { if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />; return <LoadingSpinner message={t("general.labels.loggingin")} />;
} }

View File

@@ -27,7 +27,7 @@ import Icon, {
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Layout, Menu, Space } from "antd"; import { Badge, Layout, Menu, Space, Spin } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BsKanban } from "react-icons/bs"; import { BsKanban } from "react-icons/bs";
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa"; import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
@@ -50,6 +50,7 @@ import { debounce } from "lodash";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js"; import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { NotificationCenterContainer } from "../notification-center/notification-center.container.jsx"; import { NotificationCenterContainer } from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
// Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus // Used to Determine if the Header is in Mobile Mode, and to toggle the multiple menus
const HEADER_MOBILE_BREAKPOINT = 576; const HEADER_MOBILE_BREAKPOINT = 576;
@@ -125,6 +126,57 @@ function Header({
const { t } = useTranslation(); const { t } = useTranslation();
const { isConnected } = useSocket(bodyshop);
const [notificationVisible, setNotificationVisible] = useState(false);
const [displayCount, setDisplayCount] = useState(0); // Explicit state for badge
const {
data: unreadData,
refetch: refetchUnread,
loading: unreadLoading
} = useQuery(GET_UNREAD_COUNT, {
fetchPolicy: "network-only",
pollInterval: isConnected ? 0 : 30000 // Poll only if socket is down
});
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
// Update displayCount when unreadCount changes
useEffect(() => {
console.log("Updating displayCount with unreadCount:", unreadCount);
setDisplayCount(unreadCount);
}, [unreadCount]);
// Initial fetch and socket status handling
useEffect(() => {
console.log("Running initial refetchUnread");
refetchUnread();
}, [refetchUnread]);
useEffect(() => {
if (!isConnected && !unreadLoading) {
console.log("Socket disconnected, refetching unread count");
refetchUnread();
}
}, [isConnected, unreadLoading, refetchUnread]);
// Debug logging
useEffect(() => {
console.log("Unread Count State:", {
unreadCount,
displayCount,
unreadLoading,
isConnected,
unreadData: unreadData?.notifications_aggregate,
rawUnreadData: unreadData
});
}, [unreadCount, displayCount, unreadLoading, isConnected, unreadData]);
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
const [isMobile, setIsMobile] = useState(() => { const [isMobile, setIsMobile] = useState(() => {
const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1); const effectiveWidth = window.innerWidth / (window.devicePixelRatio || 1);
return effectiveWidth <= HEADER_MOBILE_BREAKPOINT; return effectiveWidth <= HEADER_MOBILE_BREAKPOINT;
@@ -135,39 +187,6 @@ function Header({
setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT); setIsMobile(effectiveWidth <= HEADER_MOBILE_BREAKPOINT);
}, 200); }, 200);
const [notificationVisible, setNotificationVisible] = useState(false);
const {
data: unreadData,
error: unreadError,
refetch: refetchUnread,
loading: unreadLoading
} = useQuery(GET_UNREAD_COUNT, {
fetchPolicy: "network-only", // Force network request for fresh data
pollInterval: 30000, // Poll every 30 seconds to ensure updates
onError: (err) => {
console.error("Error fetching unread count:", err);
console.log("Unread data state:", unreadData, "Error details:", err);
}
});
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count || 0;
// Refetch unread count when the component mounts, updates, or on specific events
useEffect(() => {
refetchUnread();
}, [refetchUnread, bodyshop, currentUser]); // Add dependencies to trigger refetch on user or shop changes
// Log unread count for debugging
useEffect(() => {
console.log("Unread count updated:", unreadCount, "Loading:", unreadLoading, "Error:", unreadError);
}, [unreadCount, unreadLoading, unreadError]);
const handleNotificationClick = (e) => {
setNotificationVisible(!notificationVisible);
if (handleMenuClick) handleMenuClick(e);
};
useEffect(() => { useEffect(() => {
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
window.addEventListener("orientationchange", handleResize); window.addEventListener("orientationchange", handleResize);
@@ -688,8 +707,10 @@ function Header({
// Right-aligned items on desktop, merged on mobile // Right-aligned items on desktop, merged on mobile
{ {
key: "notifications", key: "notifications",
icon: ( icon: unreadLoading ? (
<Badge count={unreadCount}> <Spin size="small" />
) : (
<Badge count={displayCount}>
<BellFilled /> <BellFilled />
</Badge> </Badge>
), ),

View File

@@ -1,10 +1,12 @@
// notification-center.component.jsx
import React from "react"; import React from "react";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { Button, Checkbox, List, Badge, Typography, Alert } from "antd"; import { Button, Checkbox, List, Badge, Typography, Alert } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import "./notification-center.styles.scss"; import "./notification-center.styles.scss";
import { Link } from "react-router-dom";
const { Text } = Typography; const { Text, Title } = Typography;
const NotificationCenterComponent = ({ const NotificationCenterComponent = ({
visible, visible,
@@ -14,25 +16,35 @@ const NotificationCenterComponent = ({
error, error,
showUnreadOnly, showUnreadOnly,
toggleUnreadOnly, toggleUnreadOnly,
markAllRead markAllRead,
loadMore
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const renderNotification = (index, notification) => { const renderNotification = (index, notification) => {
console.log(`Rendering notification ${index}:`, { console.log("Rendering notification at index:", index, notification);
id: notification.id,
scenarioTextLength: notification.scenarioText.length,
read: notification.read,
created_at: notification.created_at,
associationid: notification.associationid
});
return ( return (
<List.Item <List.Item
key={`${notification.id}-${index}`} // Ensure unique key per render key={`${notification.id}-${index}`}
className={notification.read ? "notification-read" : "notification-unread"} className={notification.read ? "notification-read" : "notification-unread"}
> >
<Badge dot={!notification.read}> <Badge dot={!notification.read}>
<div> <div>
{/* RO number as title/link */}
<Title
level={5}
style={{
margin: "0 0 8px 0",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Link to={`/manage/jobs/${notification.jobid}`} target="_blank">
RO #{notification.roNumber}
</Link>
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
</Title>
<Text strong={!notification.read}> <Text strong={!notification.read}>
<ul> <ul>
{notification.scenarioText.map((text, idx) => ( {notification.scenarioText.map((text, idx) => (
@@ -40,48 +52,37 @@ const NotificationCenterComponent = ({
))} ))}
</ul> </ul>
</Text> </Text>
<Text type="secondary">{new Date(notification.created_at).toLocaleString()}</Text>
{notification.associationid && <Text type="secondary">Association ID: {notification.associationid}</Text>}
</div> </div>
</Badge> </Badge>
</List.Item> </List.Item>
); );
}; };
console.log( console.log("NotificationCenterComponent render:", { notifications, loading, error, showUnreadOnly });
"Rendering NotificationCenter with notifications:",
notifications.length,
notifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at,
associationid: n.associationid
}))
);
return ( return (
<div className={`notification-center ${visible ? "visible" : ""}`}> <div className={`notification-center ${visible ? "visible" : ""}`}>
<div className="notification-header"> <div className="notification-header">
<h3>{t("notifications.title", "Notifications")}</h3> <h3>{t("notifications.labels.new-notification-title")}</h3>
<div className="notification-controls"> <div className="notification-controls">
<Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}> <Checkbox checked={showUnreadOnly} onChange={(e) => toggleUnreadOnly(e.target.checked)}>
{t("notifications.showUnreadOnly", "Show Unread Only")} {t("notifications.labels.show-unread-only")}
</Checkbox> </Checkbox>
<Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}> <Button type="link" onClick={markAllRead} disabled={!notifications.some((n) => !n.read)}>
{t("notifications.markAllRead", "Mark All Read")} {t("notifications.labels.mark-all-read")}
</Button> </Button>
</div> </div>
</div> </div>
{error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />} {error && <Alert message="Error" description={error} type="error" closable onClose={() => onClose()} />}
<Virtuoso <Virtuoso
style={{ height: "400px", width: "100%", overflow: "auto" }} // Default Virtuoso styling style={{ height: "400px", width: "100%" }}
data={notifications} data={notifications}
totalCount={notifications.length} totalCount={notifications.length}
overscan={20} overscan={200}
increaseViewportBy={200} endReached={loadMore}
itemContent={renderNotification} itemContent={renderNotification}
/> />
{loading && !error && <div>{t("notifications.loading", "Loading...")}</div>} {loading && !error && <div>{t("notifications.labels.loading")}</div>}
</div> </div>
); );
}; };

View File

@@ -1,13 +1,16 @@
// notification-center.container.jsx
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation } from "@apollo/client"; import { useQuery, useMutation } from "@apollo/client";
import { connect } from "react-redux"; import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component"; import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries"; import { GET_NOTIFICATIONS, MARK_ALL_NOTIFICATIONS_READ } from "../../graphql/notifications.queries";
import { useSocket } from "../../contexts/SocketIO/socketContext.jsx";
export function NotificationCenterContainer({ visible, onClose }) { export function NotificationCenterContainer({ visible, onClose, bodyshop }) {
const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { isConnected } = useSocket();
const { const {
data, data,
@@ -19,106 +22,115 @@ export function NotificationCenterContainer({ visible, onClose }) {
variables: { variables: {
limit: 20, limit: 20,
offset: 0, offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {} // Default to all notifications where: showUnreadOnly ? { read: { _is_null: true } } : {}
}, },
fetchPolicy: "cache-and-network", // Ensure reactivity to cache updates fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
pollInterval: isConnected ? 0 : 30000,
skip: false,
onError: (err) => { onError: (err) => {
setError(err.message); setError(err.message);
console.error("GET_NOTIFICATIONS error:", err);
setTimeout(() => refetch(), 2000); setTimeout(() => refetch(), 2000);
},
onCompleted: (data) => {
console.log("GET_NOTIFICATIONS completed:", data?.notifications);
}
});
const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
update: (cache, { data: mutationData }) => {
const timestamp = new Date().toISOString();
cache.modify({
fields: {
notifications(existing = [], { readField }) {
return existing.map((notif) => {
if (readField("read", notif) === null) {
return { ...notif, read: timestamp };
}
return notif;
});
},
notifications_aggregate() {
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
}
}
});
if (isConnected) {
const cachedNotifications = cache.readQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: 20,
offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {}
}
});
if (cachedNotifications?.notifications) {
cache.writeQuery({
query: GET_NOTIFICATIONS,
variables: {
limit: 20,
offset: 0,
where: showUnreadOnly ? { read: { _is_null: true } } : {}
},
data: {
notifications: cachedNotifications.notifications.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif
)
}
});
}
}
},
onError: (err) => {
setError(err.message);
console.error("MARK_ALL_NOTIFICATIONS_READ error:", err);
} }
}); });
useEffect(() => { useEffect(() => {
console.log( console.log("Data changed:", data);
"Notifications data updated:",
data?.notifications?.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at,
associationid: n.associationid
}))
);
}, [data]);
const [markAllReadMutation, { error: mutationError }] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
update: (cache) => {
cache.modify({
fields: {
notifications(existing = []) {
return existing.map((notif) => ({
...notif,
read: notif.read || new Date().toISOString()
}));
},
notifications_aggregate() {
return { aggregate: { count: 0 } };
}
}
});
},
onError: (err) => setError(err.message)
});
// Remove refetchNotifications function and useEffect/context logic
useEffect(() => {
if (data?.notifications) { if (data?.notifications) {
const processedNotifications = data.notifications const processedNotifications = data.notifications
.map((notif) => { .map((notif) => {
let scenarioText; let scenarioText;
let scenarioMeta; let scenarioMeta;
try { try {
scenarioText = scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
typeof notif.scenario_text === "string" ? JSON.parse(notif.scenario_text) : notif.scenario_text || []; scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
scenarioMeta =
typeof notif.scenario_meta === "string" ? JSON.parse(notif.scenario_meta) : notif.scenario_meta || [];
} catch (e) { } catch (e) {
console.error("Error parsing JSON for notification:", notif.id, e); console.error("Error parsing JSON for notification:", notif.id, e);
scenarioText = [notif.fcm_text || "Invalid notification data"]; scenarioText = [notif.fcm_text || "Invalid notification data"];
scenarioMeta = []; scenarioMeta = {};
} }
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText]; if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
// Derive RO number from scenario_meta or assume it's available in notif
const roNumber = notif.job.ro_number || "RO Not Found"; // Adjust based on your data structure
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta]; if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
const processed = {
console.log("Processed notification:", {
id: notif.id,
scenarioText,
scenarioMeta,
read: notif.read,
created_at: notif.created_at,
associationid: notif.associationid,
raw: notif
});
return {
id: notif.id, id: notif.id,
jobid: notif.jobid, jobid: notif.jobid,
associationid: notif.associationid, associationid: notif.associationid,
scenarioText, scenarioText,
scenarioMeta, scenarioMeta,
roNumber, // Add RO number to notification object
created_at: notif.created_at, created_at: notif.created_at,
read: notif.read, read: notif.read,
__typename: notif.__typename __typename: notif.__typename
}; };
console.log("Processed notification with RO:", processed);
return processed;
}) })
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // Explicitly sort by created_at desc .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log("Setting notifications with RO:", processedNotifications);
console.log(
"Processed Notifications:",
processedNotifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at,
associationid: n.associationid
}))
);
console.log("Number of notifications to render:", processedNotifications.length);
setNotifications(processedNotifications); setNotifications(processedNotifications);
setError(null); setError(null);
} else { } else {
console.log("No data yet or error in data:", data, queryError); console.log("No notifications in data:", data);
} }
}, [data, queryError]); }, [data]);
useEffect(() => { useEffect(() => {
if (queryError || mutationError) { if (queryError || mutationError) {
@@ -126,56 +138,61 @@ export function NotificationCenterContainer({ visible, onClose }) {
} }
}, [queryError, mutationError]); }, [queryError, mutationError]);
useEffect(() => {
console.log("Notifications state updated:", notifications);
}, [notifications]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (!loading && data?.notifications.length) { if (!loading && data?.notifications.length) {
console.log("Loading more notifications, current length:", data.notifications.length);
fetchMore({ fetchMore({
variables: { offset: data.notifications.length }, variables: { offset: data.notifications.length },
updateQuery: (prev, { fetchMoreResult }) => { updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev; if (!fetchMoreResult) return prev;
console.log("Fetched more:", fetchMoreResult.notifications);
return { return {
notifications: [...prev.notifications, ...fetchMoreResult.notifications] notifications: [...prev.notifications, ...fetchMoreResult.notifications]
}; };
} }
}).catch((err) => setError(err.message)); }).catch((err) => {
setError(err.message);
console.error("Fetch more error:", err);
});
} }
}, [data?.notifications?.length, fetchMore, loading]); }, [data?.notifications?.length, fetchMore, loading]);
const handleToggleUnreadOnly = (value) => { const handleToggleUnreadOnly = (value) => {
setShowUnreadOnly(value); setShowUnreadOnly(value);
console.log("Toggled showUnreadOnly:", value);
}; };
const handleMarkAllRead = () => { const handleMarkAllRead = () => {
markAllReadMutation().catch((e) => console.log("Marking all notifications as read");
console.error(`Something went wrong marking all notifications read: ${e?.message || ""}`) markAllReadMutation()
); .then(() => {
const timestamp = new Date().toISOString();
setNotifications((prev) => {
const updatedNotifications = prev.map((notif) =>
notif.read === null ? { ...notif, read: timestamp } : notif
);
console.log("Updated notifications after mark all read:", updatedNotifications);
return [...updatedNotifications];
});
})
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`));
}; };
useEffect(() => { useEffect(() => {
if (visible) { if (visible && !isConnected) {
const virtuosoRef = { current: null }; console.log("Notification Center opened, socket disconnected, refetching...");
virtuosoRef.current?.scrollTo({ top: 0 }); refetch();
const handleScroll = () => { } else if (visible && isConnected) {
if (virtuosoRef.current && virtuosoRef.current.scrollTop + 400 >= virtuosoRef.current.scrollHeight) { console.log("Notification Center opened, socket connected, relying on cache...");
loadMore(); refetch(); // Ensure fresh data even with socket
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
} }
}, [visible, loading, data, loadMore]); }, [visible, isConnected, refetch]);
console.log(
"Rendering NotificationCenter with notifications:",
notifications.length,
notifications.map((n) => ({
id: n.id,
read: n.read,
created_at: n.created_at
}))
);
return ( return (
// Remove NotificationContext.Provider
<NotificationCenterComponent <NotificationCenterComponent
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
@@ -185,8 +202,9 @@ export function NotificationCenterContainer({ visible, onClose }) {
showUnreadOnly={showUnreadOnly} showUnreadOnly={showUnreadOnly}
toggleUnreadOnly={handleToggleUnreadOnly} toggleUnreadOnly={handleToggleUnreadOnly}
markAllRead={handleMarkAllRead} markAllRead={handleMarkAllRead}
loadMore={loadMore}
/> />
); );
} }
export default connect(null, null)(NotificationCenterContainer); export default connect((state) => ({ bodyshop: state.user.bodyshop }), null)(NotificationCenterContainer);

View File

@@ -2,12 +2,12 @@
position: absolute; position: absolute;
top: 64px; top: 64px;
right: 0; right: 0;
width: 400px; width: 600px;
background: #2a2d34; background: #fff; /* White background, Ants default */
color: #d9d9d9; color: rgba(0, 0, 0, 0.85); /* Primary text color in Ant 5 */
border: 1px solid #434343; border: 1px solid #d9d9d9; /* Neutral gray border */
border-radius: 4px; border-radius: 6px; /* Slightly larger radius per Ant 5 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); /* Subtle Ant 5 shadow */
z-index: 1000; z-index: 1000;
display: none; display: none;
@@ -17,16 +17,16 @@
.notification-header { .notification-header {
padding: 16px; padding: 16px;
border-bottom: 1px solid #434343; border-bottom: 1px solid #f0f0f0; /* Light gray border from Ant 5 */
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background: #1f2229; background: #fafafa; /* Light gray background for header */
h3 { h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
color: #ffffff; color: rgba(0, 0, 0, 0.85); /* Primary text color */
} }
.notification-controls { .notification-controls {
@@ -35,60 +35,79 @@
gap: 16px; gap: 16px;
.ant-checkbox-wrapper { .ant-checkbox-wrapper {
color: #d9d9d9; color: rgba(0, 0, 0, 0.85); /* Match Ants text color */
} }
.ant-btn-link { .ant-btn-link {
color: #40c4ff; color: #1677ff; /* Ant 5 primary blue */
&:hover { color: #80deea; } &:hover {
&:disabled { color: #616161; } color: #69b1ff; /* Lighter blue on hover */
}
&:disabled {
color: rgba(0, 0, 0, 0.25); /* Disabled text color from Ant 5 */
}
} }
} }
} }
.notification-read { .notification-read {
background: #2a2d34; background: #fff; /* White background for read items */
color: #b0b0b0; color: rgba(0, 0, 0, 0.65); /* Secondary text color */
} }
.notification-unread { .notification-unread {
background: #353942; background: #f5f5f5; /* Very light gray for unread items */
color: #ffffff; color: rgba(0, 0, 0, 0.85); /* Primary text color */
} }
.ant-list { .ant-list {
overflow: auto; // Match Virtuosos default scrolling behavior overflow: auto; /* Match Virtuosos default scrolling behavior */
max-height: 100%; // Allow full height, let Virtuoso handle virtualization max-height: 100%; /* Allow full height, let Virtuoso handle virtualization */
} }
.ant-list-item { .ant-list-item {
padding: 12px 16px; padding: 2px 16px;
border-bottom: 1px solid #434343; border-bottom: 1px solid #f0f0f0; /* Light gray border */
display: block; // Ensure visibility display: block; /* Ensure visibility */
overflow: visible; // Prevent clipping within items overflow: visible; /* Prevent clipping within items */
min-height: 80px; // Minimum height to ensure visibility of multi-line content min-height: 80px; /* Minimum height for multi-line content */
.ant-typography { color: inherit; } .ant-typography {
.ant-typography-secondary { font-size: 12px; color: #b0b0b0; } color: inherit; /* Inherit from parent (read/unread) */
.ant-badge-dot { background: #ff4d4f; } }
.ant-typography-secondary {
font-size: 12px;
color: rgba(0, 0, 0, 0.45); /* Ant 5 secondary text color */
}
.ant-badge-dot {
background: #ff4d4f; /* Keep red dot for unread, consistent with Ant */
}
ul { ul {
margin: 0; margin: 0;
padding-left: 20px; // Standard list padding padding-left: 20px; /* Standard list padding */
list-style-type: disc; // Ensure bullet points list-style-type: disc; /* Ensure bullet points */
} }
li { li {
margin-bottom: 4px; // Space between list items margin-bottom: 4px; /* Space between list items */
} }
} }
.ant-alert { .ant-alert {
margin: 8px; margin: 8px;
background: #ff4d4f; background: #fff1f0; /* Light red background for error per Ant 5 */
color: #fff; color: rgba(0, 0, 0, 0.85);
border: none; border: 1px solid #ffa39e; /* Light red border */
.ant-alert-message { color: #fff; } .ant-alert-message {
.ant-alert-description { color: #ffebee; } color: #ff4d4f; /* Red text for message */
}
.ant-alert-description {
color: rgba(0, 0, 0, 0.65); /* Slightly muted description */
}
} }
} }

View File

@@ -1,13 +1,261 @@
import React, { createContext } from "react"; // contexts/SocketIO/socketContext.jsx
import useSocket from "./useSocket"; import React, { createContext, useContext, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient";
import { useNotification } from "../Notifications/notificationContext.jsx";
import { gql } from "@apollo/client";
// Create the SocketContext
const SocketContext = createContext(null); const SocketContext = createContext(null);
export const SocketProvider = ({ children, bodyshop }) => { export const SocketProvider = ({ children, bodyshop }) => {
const { socket, clientId } = useSocket(bodyshop); const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>; useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id || socketRef.current) return; // Prevent multiple instances
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
console.log("Socket connected, ID:", socketInstance.id);
};
const handleReconnect = () => {
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
setIsConnected(false);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
const { jobId, bodyShopId, notificationId, associationId, notifications } = data;
console.log("Socket Notification Received (ID:", notificationId, "):", {
jobId,
bodyShopId,
associationId,
notifications
});
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId || null,
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null
};
try {
const existingNotifications =
client.cache.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`
})?.notifications || [];
if (existingNotifications.some((n) => n.id === newNotification.id)) {
console.log("Duplicate notification detected, skipping:", notificationId);
return;
}
client.cache.writeQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`,
data: {
notifications: [newNotification, ...existingNotifications].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
},
broadcast: true
});
console.log("Cache updated with new notification:", newNotification);
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const isUnread = newNotification.read === null;
const countChange = isUnread ? 1 : 0;
console.log("Updating unread count from socket:", existing.aggregate.count + countChange);
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + countChange
}
};
}
}
});
notification.info({
message: "New Notification",
description: (
<ul>
{notifications.map((notif, index) => (
<li key={index}>{notif.body}</li>
))}
</ul>
),
placement: "topRight",
duration: 5
});
} catch (error) {
console.error("Error in handleNotification:", error);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
console.log("Raw socket message:", message);
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
};
}, [bodyshop, notification]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, clientId, isConnected }}>
{children}
</SocketContext.Provider>
);
};
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within a SocketProvider");
}
return context;
}; };
export default SocketContext; export default SocketContext;

View File

@@ -1,241 +0,0 @@
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import client from "../../utils/GraphQLClient";
import { useNotification } from "../Notifications/notificationContext.jsx";
import { gql } from "@apollo/client";
const useSocket = (bodyshop) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const notification = useNotification();
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
console.log("Socket connected, ID:", socketInstance.id);
};
const handleReconnect = () => {
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.connect();
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
store.dispatch(setWssStatus("disconnected"));
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000);
}
};
const handleNotification = (data) => {
const { jobId, bodyShopId, notificationId, associationId, notifications } = data; // Changed to associationId (capital I)
console.log("handleNotification - Received", {
jobId,
bodyShopId,
notificationId,
associationId,
notifications
});
// Construct the notification object to match the cache/database format
const newNotification = {
__typename: "notifications",
id: notificationId,
jobid: jobId,
associationid: associationId || null, // Use associationId from socket, default to null if missing
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)), // Stringify as in the cache
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})), // Stringify as in the cache
created_at: new Date(notifications[0].timestamp).toISOString(),
read: null // Assume unread unless specified otherwise
};
try {
// Use writeQuery to add the new notification to the cache, ensuring normalization and broadcasting
client.cache.writeQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`,
data: {
notifications: [
newNotification,
...(
client.cache.readQuery({
query: gql`
query GetNotifications {
notifications(order_by: { created_at: desc }) {
__typename
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`
})?.notifications || []
).filter((n) => n.id !== newNotification.id)
].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) // Sort by created_at desc
},
broadcast: true // Notify dependent queries of the cache update
});
console.log("Cache updated with new notification:", newNotification);
// Update notifications_aggregate for unread count
client.cache.modify({
id: "ROOT_QUERY",
fields: {
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
const isUnread = newNotification.read === null;
const countChange = isUnread ? 1 : 0;
console.log("Updating unread count from socket:", existing.aggregate.count + countChange);
return {
...existing,
aggregate: {
...existing.aggregate,
count: existing.aggregate.count + countChange
}
};
}
}
});
notification.info({
message: "New Notification",
description: (
<ul>
{notifications.map((notif, index) => (
<li key={index}>{notif.body}</li>
))}
</ul>
),
placement: "topRight",
duration: 5
});
} catch (error) {
console.error("Error in handleNotification:", error);
}
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("message", (message) => {
console.log("Raw socket message:", message);
try {
if (typeof message === "string" && message.startsWith("42")) {
const parsedMessage = JSON.parse(message.slice(2));
const [event, data] = parsedMessage;
if (event === "notification") handleNotification(data);
} else if (Array.isArray(message)) {
const [event, data] = message;
if (event === "notification") handleNotification(data);
}
} catch (error) {
console.error("Error parsing socket message:", error);
}
});
socketInstance.on("notification", handleNotification);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token);
}
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
}
});
return () => {
unsubscribe();
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
};
}, [bodyshop, notification]);
return { socket: socketRef.current, clientId };
};
export default useSocket;

View File

@@ -11,6 +11,9 @@ export const GET_NOTIFICATIONS = gql`
scenario_meta scenario_meta
created_at created_at
read read
job {
ro_number
}
} }
} }
`; `;
@@ -32,18 +35,3 @@ export const MARK_ALL_NOTIFICATIONS_READ = gql`
} }
} }
`; `;
export const SUBSCRIBE_TO_NOTIFICATIONS = gql`
subscription SubscribeToNotifications {
notifications(where: { read: { _is_null: true } }, order_by: { created_at: desc }) {
id
jobid
associationid
scenario_text
fcm_text
scenario_meta
created_at
read
}
}
`;

View File

@@ -3769,6 +3769,7 @@
}, },
"notifications": { "notifications": {
"labels": { "labels": {
"notification-center": "Notification Center",
"scenario": "Scenario", "scenario": "Scenario",
"notificationscenarios": "Job Notification Scenarios", "notificationscenarios": "Job Notification Scenarios",
"save": "Save Scenarios", "save": "Save Scenarios",