IO-3166-Global-Notifications-Part-2 - Checkpoint

This commit is contained in:
Dave Richer
2025-03-05 11:43:05 -05:00
parent f8ae6dc5af
commit 059067bc61
9 changed files with 220 additions and 147 deletions

View File

@@ -2,106 +2,115 @@ import { Virtuoso } from "react-virtuoso";
import { Alert, Badge, Button, Space, Spin, Tooltip, Typography } from "antd"; import { Alert, Badge, Button, Space, Spin, Tooltip, Typography } from "antd";
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons"; import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./notification-center.styles.scss"; import "./notification-center.styles.scss";
import day from "../../utils/day.js"; import day from "../../utils/day.js";
import { forwardRef } from "react";
const { Text, Title } = Typography; const { Text, Title } = Typography;
const NotificationCenterComponent = ({ /**
visible, * Notification Center Component
onClose, * @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly error?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
notifications, */
loading, const NotificationCenterComponent = forwardRef(
error, (
showUnreadOnly, {
toggleUnreadOnly, visible,
markAllRead, onClose,
loadMore, notifications,
onNotificationClick, loading,
unreadCount error,
}) => { showUnreadOnly,
const { t } = useTranslation(); toggleUnreadOnly,
markAllRead,
loadMore,
onNotificationClick,
unreadCount
},
ref
) => {
const { t } = useTranslation();
const navigate = useNavigate();
const renderNotification = (index, notification) => { const renderNotification = (index, notification) => {
return ( const handleClick = () => {
<div if (!notification.read) {
key={`${notification.id}-${index}`} onNotificationClick(notification.id);
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`} }
onClick={() => !notification.read && onNotificationClick(notification.id)} navigate(`/manage/jobs/${notification.jobid}`);
> };
<Badge dot={!notification.read}>
<div className="notification-content"> return (
<Title level={5} className="notification-title"> <div
<Link key={`${notification.id}-${index}`}
to={`/manage/jobs/${notification.jobid}`} className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
onClick={(e) => { onClick={handleClick}
e.stopPropagation(); >
if (!notification.read) { <Badge dot={!notification.read}>
onNotificationClick(notification.id); <div className="notification-content">
} <Title level={5} className="notification-title">
}} <span className="ro-number">
className="ro-number" {t("notifications.labels.ro-number", { ro_number: notification.roNumber })}
> </span>
{t("notifications.labels.ro-number", { ro_number: notification.roNumber })} <Text
</Link> type="secondary"
<Text className="relative-time"
type="secondary" title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")}
className="relative-time" >
title={day(notification.created_at).format("YYYY-MM-DD hh:mm A")} {day(notification.created_at).fromNow()}
> </Text>
{day(notification.created_at).fromNow()} </Title>
<Text strong={!notification.read} className="notification-body">
<ul>
{notification.scenarioText.map((text, idx) => (
<li key={`${notification.id}-${idx}`}>{text}</li>
))}
</ul>
</Text> </Text>
</Title> </div>
<Text strong={!notification.read} className="notification-body"> </Badge>
<ul> </div>
{notification.scenarioText.map((text, idx) => ( );
<li key={`${notification.id}-${idx}`}>{text}</li> };
))}
</ul> return (
</Text> <div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
<div className="notification-header">
<Space direction="horizontal">
<h3>{t("notifications.labels.notification-center")}</h3>
{loading && !error && <Spin spinning={loading} size="small"></Spin>}
</Space>
<div className="notification-controls">
<Tooltip title={t("notifications.labels.show-unread-only")}>
<Button
type="link"
icon={showUnreadOnly ? <EyeFilled /> : <EyeOutlined />}
onClick={() => toggleUnreadOnly(!showUnreadOnly)}
className={showUnreadOnly ? "active" : ""}
/>
</Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}>
<Button
type="link"
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
onClick={markAllRead}
disabled={!unreadCount}
/>
</Tooltip>
</div> </div>
</Badge> </div>
{error && <Alert message={error} type="error" closable onClose={() => onClose()} />}
<Virtuoso
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
</div> </div>
); );
}; }
);
return (
<div className={`notification-center ${visible ? "visible" : ""}`}>
<div className="notification-header">
<Space direction="horizontal">
<h3>{t("notifications.labels.notification-center")}</h3>
{loading && !error && <Spin spinning={loading} size="small"></Spin>}
</Space>
<div className="notification-controls">
<Tooltip title={t("notifications.labels.show-unread-only")}>
<Button
type="link"
icon={showUnreadOnly ? <EyeFilled /> : <EyeOutlined />}
onClick={() => toggleUnreadOnly(!showUnreadOnly)}
className={showUnreadOnly ? "active" : ""}
/>
</Tooltip>
<Tooltip title={t("notifications.labels.mark-all-read")}>
<Button
type="link"
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
onClick={markAllRead}
disabled={!unreadCount}
/>
</Tooltip>
</div>
</div>
{error && <Alert message={error} type="error" closable onClose={() => onClose()} />}
<Virtuoso
style={{ height: "400px", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
</div>
);
};
export default NotificationCenterComponent; export default NotificationCenterComponent;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // Add useRef
import { useQuery } from "@apollo/client"; import { useQuery } 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";
@@ -11,11 +11,21 @@ import day from "../../utils/day.js";
// This will be used to poll for notifications when the socket is disconnected // This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60; const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
export function NotificationCenterContainer({ visible, onClose, bodyshop, unreadCount }) { /**
* Notification Center Container
* @param visible
* @param onClose
* @param bodyshop
* @param unreadCount
* @returns {JSX.Element}
* @constructor
*/
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
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, markNotificationRead, markAllNotificationsRead } = useSocket(); const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
const notificationRef = useRef(null); // Add ref for the notification center
const userAssociationId = bodyshop?.associations?.[0]?.id; const userAssociationId = bodyshop?.associations?.[0]?.id;
@@ -50,6 +60,19 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
} }
}); });
// Handle click outside to close
useEffect(() => {
const handleClickOutside = (event) => {
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [visible, onClose]);
useEffect(() => { useEffect(() => {
if (data?.notifications) { if (data?.notifications) {
const processedNotifications = data.notifications const processedNotifications = data.notifications
@@ -159,6 +182,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
return ( return (
<NotificationCenterComponent <NotificationCenterComponent
ref={notificationRef}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
notifications={notifications} notifications={notifications}
@@ -172,7 +196,7 @@ export function NotificationCenterContainer({ visible, onClose, bodyshop, unread
unreadCount={unreadCount} unreadCount={unreadCount}
/> />
); );
} };
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop

View File

@@ -11,7 +11,7 @@
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
z-index: 1000; z-index: 1000;
display: none; display: none;
overflow-x: hidden; /* Prevent horizontal overflow */ overflow-x: hidden;
&.visible { &.visible {
display: block; display: block;
@@ -67,12 +67,17 @@
} }
.notification-item { .notification-item {
padding: 8px 16px; padding: 12px 16px; // Increased padding from 8px to 12px for more space
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
display: block; display: block;
overflow: visible; overflow: visible;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; // Add pointer cursor to indicate clickability
&:hover {
background: #fafafa; // Optional: Add hover effect for better UX
}
.notification-content { .notification-content {
width: 100%; width: 100%;
@@ -122,7 +127,7 @@
} }
.ant-badge { .ant-badge {
width: 100%; /* Ensure Badge takes full width to allow .notification-title to stretch properly */ width: 100%;
} }
.ant-alert { .ant-alert {

View File

@@ -0,0 +1,56 @@
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import { Checkbox, Form } from "antd";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
/**
* ColumnHeaderCheckbox
* @param channel
* @param form
* @param disabled
* @param onHeaderChange
* @returns {JSX.Element}
* @constructor
*/
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
const { t } = useTranslation();
// Subscribe to all form values so that this component re-renders on changes.
const formValues = Form.useWatch([], form) || {};
// Determine if all scenarios for this channel are checked.
const allChecked =
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
// Get current form values.
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
form.setFieldsValue(newValues);
// Manually mark the form as dirty.
if (onHeaderChange) {
onHeaderChange();
}
};
return (
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
{t(`notifications.channels.${channel}`)}
</Checkbox>
);
};
ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
};
export default ColumnHeaderCheckbox;

View File

@@ -2,7 +2,6 @@ import { useMutation, useQuery } from "@apollo/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Card, Checkbox, Form, Space, Table } from "antd"; import { Button, Card, Checkbox, Form, Space, Table } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../../redux/user/user.selectors"; import { selectCurrentUser } from "../../redux/user/user.selectors";
@@ -11,52 +10,21 @@ import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../..
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js"; import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx"; import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => { /**
const { t } = useTranslation(); * Notifications Settings Form
// Subscribe to all form values so that this component re-renders on changes. * @param currentUser
const formValues = Form.useWatch([], form) || {}; * @returns {JSX.Element}
* @constructor
// Determine if all scenarios for this channel are checked. */
const allChecked = const NotificationSettingsForm = ({ currentUser }) => {
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
const onChange = (e) => {
const checked = e.target.checked;
// Get current form values.
const currentValues = form.getFieldsValue();
// Update each scenario for this channel.
const newValues = { ...currentValues };
notificationScenarios.forEach((scenario) => {
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
});
// Update form values.
form.setFieldsValue(newValues);
// Manually mark the form as dirty.
if (onHeaderChange) {
onHeaderChange();
}
};
return (
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
{t(`notifications.channels.${channel}`)}
</Checkbox>
);
};
ColumnHeaderCheckbox.propTypes = {
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
form: PropTypes.object.isRequired,
disabled: PropTypes.bool,
onHeaderChange: PropTypes.func
};
function NotificationSettingsForm({ currentUser }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState({}); const [initialValues, setInitialValues] = useState({});
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const notification = useNotification();
// Fetch notification settings. // Fetch notification settings.
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, { const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
@@ -88,9 +56,14 @@ function NotificationSettingsForm({ currentUser }) {
if (data?.associations?.length > 0) { if (data?.associations?.length > 0) {
const userId = data.associations[0].id; const userId = data.associations[0].id;
// Save the updated notification settings. // Save the updated notification settings.
await updateNotificationSettings({ variables: { id: userId, ns: values } }); const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
setInitialValues(values); if (!result?.errors) {
setIsDirty(false); notification.success({ message: t("notifications.labels.notification-settings-success") });
setInitialValues(values);
setIsDirty(false);
} else {
notification.error({ message: t("notifications.labels.notification-settings-failure") });
}
} }
}; };
@@ -180,7 +153,7 @@ function NotificationSettingsForm({ currentUser }) {
</Card> </Card>
</Form> </Form>
); );
} };
NotificationSettingsForm.propTypes = { NotificationSettingsForm.propTypes = {
currentUser: PropTypes.shape({ currentUser: PropTypes.shape({

View File

@@ -8,8 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils"; import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import NotificationSettingsForm from "./notification-settings.component.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser currentUser: selectCurrentUser

View File

@@ -3783,7 +3783,9 @@
"mark-all-read": "Mark Read", "mark-all-read": "Mark Read",
"notification-popup-title": "Changes for Job #{{ro_number}}", "notification-popup-title": "Changes for Job #{{ro_number}}",
"ro-number": "RO #{{ro_number}}", "ro-number": "RO #{{ro_number}}",
"no-watchers": "No Watchers" "no-watchers": "No Watchers",
"notification-settings-success": "Notification Settings saved successfully.",
"notification-settings-failure": "Error saving Notification Settings. {{error}}"
}, },
"actions": { "actions": {
"remove": "remove" "remove": "remove"

View File

@@ -3783,7 +3783,9 @@
"mark-all-read": "", "mark-all-read": "",
"notification-popup-title": "", "notification-popup-title": "",
"ro-number": "", "ro-number": "",
"no-watchers": "" "no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": ""
}, },
"actions": { "actions": {
"remove": "" "remove": ""

View File

@@ -3783,7 +3783,9 @@
"mark-all-read": "", "mark-all-read": "",
"notification-popup-title": "", "notification-popup-title": "",
"ro-number": "", "ro-number": "",
"no-watchers": "" "no-watchers": "",
"notification-settings-success": "",
"notification-settings-failure": ""
}, },
"actions": { "actions": {
"remove": "" "remove": ""