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.
This commit is contained in:
Tom Butcher 2026-09-01 11:50:47 +01:00
parent 8ab44fc9da
commit 835c287810
2 changed files with 412 additions and 53 deletions

View File

@ -21,7 +21,12 @@ const DocumentPrintButton = ({
disabled = false,
...buttonProps
}) => {
const { fetchObjects } = useContext(ApiServerContext)
const {
fetchObjects,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const fetchObjectsRef = useRef(fetchObjects)
fetchObjectsRef.current = fetchObjects
@ -30,22 +35,40 @@ const DocumentPrintButton = ({
const [loading, setLoading] = useState(false)
const [newDocumentJobOpen, setNewDocumentJobOpen] = useState(false)
const subscribedIdsRef = useRef([])
const unsubscribesRef = useRef([])
const subscribeToObjectTypeUpdatesRef = useRef(null)
const updateEventHandlerRef = useRef()
const { token } = useContext(AuthContext)
// Get the model by name
//const model = getModelByName(type)
const loadDocumentTemplates = useCallback(async () => {
if (!type || token == null) return
setLoading(true)
try {
const result = await fetchObjectsRef.current('documentTemplate', {
filter: {
const subscriptionFilter = useMemo(
() => ({
objectType: type,
global: false,
active: true
},
}),
[type]
)
const templateMatchesFilter = useCallback(
(template) =>
template?.objectType === type &&
template?.global === false &&
template?.active === true,
[type]
)
const loadDocumentTemplates = useCallback(
async (silent = false) => {
if (!type || token == null) return
if (!silent) {
setLoading(true)
}
try {
const result = await fetchObjectsRef.current('documentTemplate', {
filter: subscriptionFilter,
limit: 100 // Get more templates to show in dropdown
})
@ -55,9 +78,16 @@ const DocumentPrintButton = ({
} catch (error) {
console.error('Error fetching document templates:', error)
} finally {
if (!silent) {
setLoading(false)
}
}, [type, token])
}
},
[type, token, subscriptionFilter]
)
const loadDocumentTemplatesRef = useRef(loadDocumentTemplates)
loadDocumentTemplatesRef.current = loadDocumentTemplates
// Stable key from objectData._id (excludes _isEditing so toggling edit mode doesn't trigger template reload)
const objectKey = useMemo(() => {
@ -70,6 +100,103 @@ const DocumentPrintButton = ({
loadDocumentTemplates()
}, [objectKey, token, type, loadDocumentTemplates])
const updateEventHandler = useCallback(
(id, updatedData) => {
setDocumentTemplates((prev) => {
const existing = prev.find((template) => template._id === id)
const merged = existing
? { ...existing, ...updatedData }
: { _id: id, ...updatedData }
if (!templateMatchesFilter(merged)) {
return prev.filter((template) => template._id !== id)
}
if (existing) {
return prev.map((template) =>
template._id === id ? merged : template
)
}
return [...prev, merged]
})
},
[templateMatchesFilter]
)
updateEventHandlerRef.current = updateEventHandler
useEffect(() => {
if (connected !== true) return
const unsubscribe = subscribeToObjectTypeUpdates(
'documentTemplate',
subscriptionFilter,
() => loadDocumentTemplatesRef.current(true)
)
subscribeToObjectTypeUpdatesRef.current = unsubscribe
return () => {
if (unsubscribe) unsubscribe()
if (subscribeToObjectTypeUpdatesRef.current === unsubscribe) {
subscribeToObjectTypeUpdatesRef.current = null
}
}
}, [connected, subscriptionFilter, subscribeToObjectTypeUpdates])
useEffect(() => {
if (connected !== true) return
const templateIds = documentTemplates.map((template) => template._id).filter(Boolean)
const newTemplateIds = templateIds.filter(
(id) => !subscribedIdsRef.current.includes(id)
)
newTemplateIds.forEach((itemId) => {
const unsubscribe = subscribeToObjectUpdates(
itemId?.toLowerCase(),
'documentTemplate',
(updateData) => updateEventHandlerRef.current(itemId, updateData)
)
subscribedIdsRef.current.push(itemId)
if (unsubscribe) {
unsubscribesRef.current.push(unsubscribe)
}
})
const templateIdsToUnsubscribe = subscribedIdsRef.current.filter(
(id) => !templateIds.includes(id)
)
templateIdsToUnsubscribe.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)
}
})
}, [documentTemplates, connected, subscribeToObjectUpdates])
useEffect(() => {
return () => {
unsubscribesRef.current.forEach((unsubscribe) => {
if (unsubscribe) unsubscribe()
})
unsubscribesRef.current = []
subscribedIdsRef.current = []
if (subscribeToObjectTypeUpdatesRef.current) {
subscribeToObjectTypeUpdatesRef.current()
subscribeToObjectTypeUpdatesRef.current = null
}
}
}, [])
// Handle template selection
const handleTemplateSelect = (template) => {
setCurrentDocumentTemplate(template)
@ -78,14 +205,55 @@ const DocumentPrintButton = ({
// This could open a print dialog, navigate to a print page, etc.
}
// Create dropdown menu items
const menuItems = documentTemplates.map((template) => ({
// Group templates by tag for nested dropdown menu; untagged templates at root
const menuItems = useMemo(() => {
const templatesByTag = new Map()
const untaggedTemplates = []
const toTemplateMenuItem = (template) => ({
key: template._id,
label: template.name,
icon: <DocumentTemplateIcon />,
onClick: () => handleTemplateSelect(template)
})
for (const template of documentTemplates) {
const templateTags = Array.isArray(template.tags)
? template.tags.filter(Boolean)
: []
if (templateTags.length === 0) {
untaggedTemplates.push(template)
continue
}
for (const tag of templateTags) {
const templates = templatesByTag.get(tag) ?? []
templates.push(template)
templatesByTag.set(tag, templates)
}
}
const taggedMenuItems = [...templatesByTag.keys()]
.sort((a, b) => a.localeCompare(b))
.map((tag) => ({
key: tag,
label: tag,
children: templatesByTag
.get(tag)
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(toTemplateMenuItem)
}))
const rootUntaggedItems = untaggedTemplates
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(toTemplateMenuItem)
return [...rootUntaggedItems, ...taggedMenuItems]
}, [documentTemplates])
// If no templates available, show disabled state
if (documentTemplates.length === 0 && !loading) {
return (

View File

@ -23,10 +23,13 @@ const UserNotifierToggle = memo(({
toggleUserNotifier,
editUserNotifier,
fetchUserNotifiersForObject,
fetchAllUserNotifiersForObject
fetchAllUserNotifiersForObject,
connected,
subscribeToObjectUpdates,
subscribeToObjectTypeUpdates
} = useContext(ApiServerContext)
const { userProfile, token, authInitialized } = useContext(AuthContext)
const [isNotifying, setIsNotifying] = useState(false)
const [currentUserNotifier, setCurrentUserNotifier] = useState(null)
const [loading, setLoading] = useState(false)
const [initialLoad, setInitialLoad] = useState(true)
const [allNotifiers, setAllNotifiers] = useState([])
@ -34,8 +37,32 @@ const UserNotifierToggle = memo(({
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,
@ -46,42 +73,196 @@ const UserNotifierToggle = memo(({
fetchAllUserNotifiersForObject
}
useEffect(() => {
const loadNotifierState = useCallback(
async (silent = false) => {
if (!authReady || !objectId || !type) return
const loadNotifierState = async () => {
if (!silent) {
setInitialLoad(true)
}
try {
const { data } = await apiRef.current.fetchUserNotifiersForObject(objectId, type)
setIsNotifying(data?.length > 0)
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]
)
loadNotifierState()
}, [authReady, objectId, type])
const loadAllNotifiers = useCallback(
async (silent = false) => {
if (!authReady || !objectId || !type) return
useEffect(() => {
if (!authReady || !objectId || !type || !popoverOpen) return
const loadAllNotifiers = async () => {
if (!silent) {
setPopoverLoading(true)
}
try {
const { data } = await apiRef.current.fetchAllUserNotifiersForObject(objectId, type)
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()
}, [authReady, objectId, type, popoverOpen])
}, [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
@ -89,7 +270,12 @@ const UserNotifierToggle = memo(({
setLoading(true)
try {
const enabled = await toggleUserNotifier(objectId, type)
setIsNotifying(enabled)
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 || [])
@ -105,7 +291,7 @@ const UserNotifierToggle = memo(({
} finally {
setLoading(false)
}
}, [authReady, objectId, type, loading, popoverOpen, toggleUserNotifier, fetchAllUserNotifiersForObject])
}, [authReady, objectId, type, loading, popoverOpen, toggleUserNotifier, fetchUserNotifiersForObject, fetchAllUserNotifiersForObject])
const getUserDisplayName = useCallback((user) => {
if (!user) return 'Unknown'
@ -132,6 +318,11 @@ const UserNotifierToggle = memo(({
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'