Merged in feature/IO-3166-Global-Notifications-Part-2 (pull request #2165)

IO-3166-Global-Notifications-Part-2 - Checkpoint
This commit is contained in:
Dave Richer
2025-03-07 16:04:27 +00:00
3 changed files with 86 additions and 44 deletions

View File

@@ -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(
</div>
</div>
<Virtuoso
ref={virtuosoRef}
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // Add useRef
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
@@ -23,8 +23,9 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
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}

View File

@@ -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 querys 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;