farmcontrol-ui/src/components/Dashboard/common/UserNotifierToggle.jsx
Tom Butcher 835c287810 Enhance DocumentPrintButton and UserNotifierToggle with Subscription Logic
- Updated DocumentPrintButton to include subscription handling for document templates, improving real-time updates and user experience.
- Refactored UserNotifierToggle to manage user notifier states with enhanced subscription logic, ensuring accurate updates and improved performance.
- Introduced memoization and callback optimizations to streamline data fetching and state management in both components.
2026-09-01 11:50:47 +01:00

448 lines
13 KiB
JavaScript

import PropTypes from 'prop-types'
import { useState, useEffect, useContext, useRef, useCallback, useMemo, memo } from 'react'
import { Button, message, Popover, Typography, Space, Flex } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import BellIcon from '../../Icons/BellIcon'
import MailCheckIcon from '../../Icons/MailCheckIcon'
import MailIcon from '../../Icons/MailIcon'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { LoadingOutlined } from '@ant-design/icons'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
const { Text } = Typography
const UserNotifierToggle = memo(({
type,
objectData,
size = 'middle',
disabled = false,
...buttonProps
}) => {
const {
toggleUserNotifier,
editUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const { userProfile, token, authInitialized } = useContext(AuthContext)
const [currentUserNotifier, setCurrentUserNotifier] = useState(null)
const [loading, setLoading] = useState(false)
const [initialLoad, setInitialLoad] = useState(true)
const [allNotifiers, setAllNotifiers] = useState([])
const [popoverOpen, setPopoverOpen] = useState(false)
const [popoverLoading, setPopoverLoading] = useState(false)
const [emailTogglingId, setEmailTogglingId] = useState(null)
const subscribedIdsRef = useRef([])
const unsubscribesRef = useRef([])
const subscribeToObjectTypeUpdatesRef = useRef(null)
const updateEventHandlerRef = useRef()
const popoverOpenRef = useRef(popoverOpen)
popoverOpenRef.current = popoverOpen
const objectId = objectData?._id
const authReady = Boolean(token && authInitialized)
const isNotifying = Boolean(currentUserNotifier)
const subscriptionFilter = useMemo(
() => ({
object: objectId,
objectType: type
}),
[objectId, type]
)
const notifierMatchesFilter = useCallback(
(notifier) => {
const objectRef = notifier?.object?._id ?? notifier?.object
return objectRef === objectId && notifier?.objectType === type
},
[objectId, type]
)
const apiRef = useRef({
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
})
apiRef.current = {
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
}
const loadNotifierState = useCallback(
async (silent = false) => {
if (!authReady || !objectId || !type) return
if (!silent) {
setInitialLoad(true)
}
try {
const { data } = await apiRef.current.fetchUserNotifiersForObject(
objectId,
type
)
setCurrentUserNotifier(data?.[0] ?? null)
} catch (error) {
console.error('Error fetching user notifier state:', error)
} finally {
if (!silent) {
setInitialLoad(false)
}
}
},
[authReady, objectId, type]
)
const loadAllNotifiers = useCallback(
async (silent = false) => {
if (!authReady || !objectId || !type) return
if (!silent) {
setPopoverLoading(true)
}
try {
const { data } = await apiRef.current.fetchAllUserNotifiersForObject(
objectId,
type
)
setAllNotifiers(data || [])
} catch (error) {
console.error('Error fetching all user notifiers:', error)
setAllNotifiers([])
} finally {
if (!silent) {
setPopoverLoading(false)
}
}
},
[authReady, objectId, type]
)
const loadNotifierStateRef = useRef(loadNotifierState)
loadNotifierStateRef.current = loadNotifierState
const loadAllNotifiersRef = useRef(loadAllNotifiers)
loadAllNotifiersRef.current = loadAllNotifiers
useEffect(() => {
loadNotifierState()
}, [loadNotifierState])
useEffect(() => {
if (!popoverOpen) return
loadAllNotifiers()
}, [popoverOpen, loadAllNotifiers])
const updateEventHandler = useCallback(
(id, updatedData) => {
setAllNotifiers((prev) => {
const existing = prev.find((notifier) => notifier._id === id)
const merged = existing
? { ...existing, ...updatedData }
: { _id: id, ...updatedData }
if (!notifierMatchesFilter(merged)) {
return prev.filter((notifier) => notifier._id !== id)
}
if (existing) {
return prev.map((notifier) =>
notifier._id === id ? merged : notifier
)
}
return [...prev, merged]
})
setCurrentUserNotifier((prev) => {
if (prev?._id === id) {
const merged = { ...prev, ...updatedData }
return notifierMatchesFilter(merged) ? merged : null
}
const userRef = updatedData?.user?._id ?? updatedData?.user
if (
userRef === userProfile?._id &&
notifierMatchesFilter({ _id: id, ...updatedData })
) {
return { _id: id, ...updatedData }
}
return prev
})
},
[notifierMatchesFilter, userProfile?._id]
)
updateEventHandlerRef.current = updateEventHandler
useEffect(() => {
if (connected !== true || !objectId || !type) return
const unsubscribe = subscribeToObjectTypeUpdates(
'userNotifier',
subscriptionFilter,
() => {
loadNotifierStateRef.current(true)
if (popoverOpenRef.current) {
loadAllNotifiersRef.current(true)
}
}
)
subscribeToObjectTypeUpdatesRef.current = unsubscribe
return () => {
if (unsubscribe) unsubscribe()
if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
subscribeToObjectTypeUpdatesRef.current = null
}
}
}, [connected, subscriptionFilter, subscribeToObjectTypeUpdates, objectId, type])
const subscribedNotifierIds = useMemo(() => {
const ids = new Set(
allNotifiers.map((notifier) => notifier._id).filter(Boolean)
)
if (currentUserNotifier?._id) {
ids.add(currentUserNotifier._id)
}
return [...ids]
}, [allNotifiers, currentUserNotifier])
useEffect(() => {
if (connected !== true) return
const newNotifierIds = subscribedNotifierIds.filter(
(id) => !subscribedIdsRef.current.includes(id)
)
newNotifierIds.forEach((itemId) => {
const unsubscribe = subscribeToObjectUpdates(
itemId?.toLowerCase(),
'userNotifier',
(updateData) => updateEventHandlerRef.current(itemId, updateData)
)
subscribedIdsRef.current.push(itemId)
if (unsubscribe) {
unsubscribesRef.current.push(unsubscribe)
}
})
const notifierIdsToUnsubscribe = subscribedIdsRef.current.filter(
(id) => !subscribedNotifierIds.includes(id)
)
notifierIdsToUnsubscribe.forEach((itemId) => {
const index = subscribedIdsRef.current.indexOf(itemId)
if (index > -1) {
subscribedIdsRef.current.splice(index, 1)
const unsubscribe = unsubscribesRef.current[index]
if (unsubscribe) {
unsubscribe()
}
unsubscribesRef.current.splice(index, 1)
}
})
}, [subscribedNotifierIds, connected, subscribeToObjectUpdates])
useEffect(() => {
return () => {
unsubscribesRef.current.forEach((unsubscribe) => {
if (unsubscribe) unsubscribe()
})
unsubscribesRef.current = []
subscribedIdsRef.current = []
if (subscribeToObjectTypeUpdatesRef.current) {
subscribeToObjectTypeUpdatesRef.current()
subscribeToObjectTypeUpdatesRef.current = null
}
}
}, [])
const handleClick = useCallback(async () => {
if (!authReady || !objectId || !type || loading) return
setLoading(true)
try {
const enabled = await toggleUserNotifier(objectId, type)
if (enabled) {
const { data } = await fetchUserNotifiersForObject(objectId, type)
setCurrentUserNotifier(data?.[0] ?? null)
} else {
setCurrentUserNotifier(null)
}
if (popoverOpen) {
const { data } = await fetchAllUserNotifiersForObject(objectId, type)
setAllNotifiers(data || [])
}
message.success(
enabled
? 'Notifications enabled for this object'
: 'Notifications disabled for this object'
)
} catch (error) {
console.error('Error toggling user notifier:', error)
message.error('Failed to update notifications')
} finally {
setLoading(false)
}
}, [authReady, objectId, type, loading, popoverOpen, toggleUserNotifier, fetchUserNotifiersForObject, fetchAllUserNotifiersForObject])
const getUserDisplayName = useCallback((user) => {
if (!user) return 'Unknown'
return (
user.name ||
user.username ||
`${user.firstName || ''} ${user.lastName || ''}`.trim() ||
user.email ||
'Unknown'
)
}, [])
const isCurrentUser = useCallback((user) => user?._id === userProfile?._id, [userProfile?._id])
const handleEmailToggle = useCallback(async (item) => {
if (!isCurrentUser(item.user) || emailTogglingId) return
setEmailTogglingId(item._id)
const newEmail = !item.email
try {
const result = await editUserNotifier(item._id, { email: newEmail })
if (result) {
setAllNotifiers((prev) =>
prev.map((n) =>
n._id === item._id ? { ...n, email: result.email ?? newEmail } : n
)
)
setCurrentUserNotifier((prev) =>
prev?._id === item._id
? { ...prev, email: result.email ?? newEmail }
: prev
)
message.success(
(result.email ?? newEmail)
? 'Email notifications enabled'
: 'Email notifications disabled'
)
}
} catch (error) {
console.error('Error toggling email:', error)
message.error('Failed to update email notifications')
} finally {
setEmailTogglingId(null)
}
}, [isCurrentUser, emailTogglingId, editUserNotifier])
const popoverContent = useMemo(
() => (
<Flex
vertical
justify='center'
style={{ minWidth: 240, minHeight: 25 }}
gap={'4px'}
>
{popoverLoading ? (
<Space size={'small'}>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Loading, please wait...</Text>
</Space>
) : allNotifiers.length === 0 ? (
<Space size={'small'}>
<Text style={{ margin: 0 }} type='secondary'>
<InfoCircleIcon />
</Text>
<Text style={{ margin: 0 }} type='secondary'>
No users subscribed.
</Text>
</Space>
) : (
<>
{[...allNotifiers]
.sort(
(a, b) =>
(isCurrentUser(b.user) ? 1 : 0) -
(isCurrentUser(a.user) ? 1 : 0)
)
.map((item) => (
<Flex key={item._id} justify='space-between' align='center'>
<Flex align='center' gap={'6px'}>
<UserOutlined />
<Text>{getUserDisplayName(item.user)}</Text>
{isCurrentUser(item.user) && (
<Text type='secondary'> (you)</Text>
)}
</Flex>
<Space size={'small'}>
<Button
type='text'
icon={
item.email ? (
<MailCheckIcon
style={{
color: 'var(--color-primary)'
}}
/>
) : (
<MailIcon />
)
}
size='small'
disabled={!isCurrentUser(item.user)}
loading={emailTogglingId === item._id}
onClick={() => handleEmailToggle(item)}
/>
</Space>
</Flex>
))}
</>
)}
</Flex>
),
[popoverLoading, allNotifiers, emailTogglingId, isCurrentUser, getUserDisplayName, handleEmailToggle]
)
return (
<Popover
content={popoverContent}
title={null}
trigger='hover'
placement='bottomLeft'
arrow={false}
open={popoverOpen}
onOpenChange={setPopoverOpen}
styles={{ body: { padding: '10px 12.5px 10px 15px' } }}
>
<Button
{...buttonProps}
icon={
<BellIcon
style={{
color: isNotifying ? 'var(--color-warning)' : undefined
}}
/>
}
disabled={disabled || loading || initialLoad || !authReady}
loading={loading || !authReady || !objectId || !type}
onClick={handleClick}
size={size}
style={{ minWidth: size === 'small' ? 24 : undefined }}
/>
</Popover>
)
})
UserNotifierToggle.displayName = 'UserNotifierToggle'
UserNotifierToggle.propTypes = {
type: PropTypes.string.isRequired,
objectData: PropTypes.object.isRequired,
disabled: PropTypes.bool,
size: PropTypes.string
}
export default UserNotifierToggle