From 11ff8e91c7d9caabc38fd5bdc22cb35b18497dce Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Fri, 7 Mar 2025 10:58:01 -0500 Subject: [PATCH] IO-3166-Global-Notifications-Part-2 - Checkpoint --- .../notification-center.component.jsx | 11 ++- .../notification-center.container.jsx | 82 +++++++++---------- client/src/utils/GraphQLClient.js | 37 ++++++++- 3 files changed, 86 insertions(+), 44 deletions(-) diff --git a/client/src/components/notification-center/notification-center.component.jsx b/client/src/components/notification-center/notification-center.component.jsx index ef7d331fe..f72d2bd48 100644 --- a/client/src/components/notification-center/notification-center.component.jsx +++ b/client/src/components/notification-center/notification-center.component.jsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import "./notification-center.styles.scss"; import day from "../../utils/day.js"; -import { forwardRef } from "react"; +import { forwardRef, useRef, useEffect } from "react"; import { DateTimeFormat } from "../../utils/DateFormatter.jsx"; const { Text, Title } = Typography; @@ -32,6 +32,14 @@ const NotificationCenterComponent = forwardRef( ) => { const { t } = useTranslation(); const navigate = useNavigate(); + const virtuosoRef = useRef(null); + + // Scroll to top when showUnreadOnly changes + useEffect(() => { + if (virtuosoRef.current) { + virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" }); + } + }, [showUnreadOnly]); const renderNotification = (index, notification) => { const handleClick = () => { @@ -99,6 +107,7 @@ const NotificationCenterComponent = forwardRef( { const [showUnreadOnly, setShowUnreadOnly] = useState(false); const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket(); - const notificationRef = useRef(null); // Add ref for the notification center + const notificationRef = useRef(null); const userAssociationId = bodyshop?.associations?.[0]?.id; @@ -36,7 +37,12 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount } return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause; }, [baseWhereClause, showUnreadOnly]); - const { data, fetchMore, loading, refetch } = useQuery(GET_NOTIFICATIONS, { + const { + data, + fetchMore, + loading: queryLoading, + refetch + } = useQuery(GET_NOTIFICATIONS, { variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, @@ -47,27 +53,21 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount } pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(), skip: !userAssociationId, onError: (err) => { - console.error(`Error polling Notifications in notification-center: ${err?.message || ""}`); + console.error(`Error polling Notifications: ${err?.message || ""}`); setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds()); } }); - // Handle click outside to close useEffect(() => { const handleClickOutside = (event) => { // Prevent open + close behavior from the header - if (event.target.closest("#header-notifications")) { - return; - } - + if (event.target.closest("#header-notifications")) return; if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) { onClose(); } }; document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; + return () => document.removeEventListener("mousedown", handleClickOutside); }, [visible, onClose]); useEffect(() => { @@ -105,7 +105,8 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount } }, [data]); const loadMore = useCallback(() => { - if (!loading && data?.notifications.length) { + if (!queryLoading && data?.notifications.length) { + setIsLoading(true); // Show spinner during fetchMore fetchMore({ variables: { offset: data.notifications.length, where: whereClause }, updateQuery: (prev, { fetchMoreResult }) => { @@ -114,58 +115,55 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount } notifications: [...prev.notifications, ...fetchMoreResult.notifications] }; } - }).catch((err) => { - console.error("Fetch more error:", err); - }); + }) + .catch((err) => { + console.error("Fetch more error:", err); + }) + .finally(() => setIsLoading(false)); // Hide spinner when done } - }, [data?.notifications?.length, fetchMore, loading, whereClause]); + }, [data?.notifications?.length, fetchMore, queryLoading, whereClause]); const handleToggleUnreadOnly = (value) => { setShowUnreadOnly(value); }; const handleMarkAllRead = useCallback(() => { + setIsLoading(true); markAllNotificationsRead() .then(() => { const timestamp = new Date().toISOString(); - setNotifications((prev) => { - const updatedNotifications = prev.map((notif) => - notif.read === null && notif.associationid === userAssociationId - ? { - ...notif, - read: timestamp - } - : notif - ); - return [...updatedNotifications]; - }); + setNotifications((prev) => + prev.map((notif) => + notif.read === null && notif.associationid === userAssociationId ? { ...notif, read: timestamp } : notif + ) + ); }) - .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)); + .catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`)) + .finally(() => setIsLoading(false)); }, [markAllNotificationsRead, userAssociationId]); const handleNotificationClick = useCallback( (notificationId) => { - markNotificationRead({ - variables: { id: notificationId } - }) + setIsLoading(true); + markNotificationRead({ variables: { id: notificationId } }) .then(() => { const timestamp = new Date().toISOString(); - setNotifications((prev) => { - return prev.map((notif) => - notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif - ); - }); + setNotifications((prev) => + prev.map((notif) => (notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif)) + ); }) - .catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`)); + .catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`)) + .finally(() => setIsLoading(false)); }, [markNotificationRead] ); useEffect(() => { if (visible && !isConnected) { - refetch().catch( - (err) => `Something went wrong re-fetching notifications in the notification-center: ${err?.message || ""}` - ); + setIsLoading(true); + refetch() + .catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`)) + .finally(() => setIsLoading(false)); } }, [visible, isConnected, refetch]); @@ -175,7 +173,7 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount } visible={visible} onClose={onClose} notifications={notifications} - loading={loading} + loading={isLoading} showUnreadOnly={showUnreadOnly} toggleUnreadOnly={handleToggleUnreadOnly} markAllRead={handleMarkAllRead} diff --git a/client/src/utils/GraphQLClient.js b/client/src/utils/GraphQLClient.js index 1e316f894..ce1b6b436 100644 --- a/client/src/utils/GraphQLClient.js +++ b/client/src/utils/GraphQLClient.js @@ -143,7 +143,41 @@ middlewares.push( new SentryLink().concat(roundTripLink.concat(retryLink.concat(errorLink.concat(authLink.concat(link))))) ); -const cache = new InMemoryCache({}); +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + // Note: This is required because we switch from a read to an unread state with a toggle, + notifications: { + merge(existing = [], incoming = [], { readField }) { + // Create a map to deduplicate by __ref + const merged = new Map(); + + // Add existing items to retain cached data + existing.forEach((item) => { + const ref = readField("__ref", item); + if (ref) { + merged.set(ref, item); + } + }); + + // Add incoming items, overwriting duplicates + incoming.forEach((item) => { + const ref = readField("__ref", item); + if (ref) { + merged.set(ref, item); + } + }); + + // Return incoming to respect the current query’s filter (e.g., unread-only or all) + return incoming; + } + } + } + } + } +}); + const client = new ApolloClient({ link: ApolloLink.from(middlewares), cache, @@ -163,4 +197,5 @@ const client = new ApolloClient({ } } }); + export default client;