From 835c287810134da43ed2f0b3ab62758a3548f110 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 1 Sep 2026 11:50:47 +0100 Subject: [PATCH] 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. --- .../Dashboard/common/DocumentPrintButton.jsx | 228 ++++++++++++++--- .../Dashboard/common/UserNotifierToggle.jsx | 237 ++++++++++++++++-- 2 files changed, 412 insertions(+), 53 deletions(-) diff --git a/src/components/Dashboard/common/DocumentPrintButton.jsx b/src/components/Dashboard/common/DocumentPrintButton.jsx index d26cb4a4..6e6d4288 100644 --- a/src/components/Dashboard/common/DocumentPrintButton.jsx +++ b/src/components/Dashboard/common/DocumentPrintButton.jsx @@ -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,34 +35,59 @@ 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 subscriptionFilter = useMemo( + () => ({ + objectType: type, + global: false, + active: true + }), + [type] + ) - const loadDocumentTemplates = useCallback(async () => { - if (!type || token == null) return + const templateMatchesFilter = useCallback( + (template) => + template?.objectType === type && + template?.global === false && + template?.active === true, + [type] + ) - setLoading(true) - try { - const result = await fetchObjectsRef.current('documentTemplate', { - filter: { - objectType: type, - global: false, - active: true - }, - limit: 100 // Get more templates to show in dropdown - }) + const loadDocumentTemplates = useCallback( + async (silent = false) => { + if (!type || token == null) return - if (result && result.data) { - setDocumentTemplates(result.data) + if (!silent) { + setLoading(true) } - } catch (error) { - console.error('Error fetching document templates:', error) - } finally { - setLoading(false) - } - }, [type, token]) + try { + const result = await fetchObjectsRef.current('documentTemplate', { + filter: subscriptionFilter, + limit: 100 // Get more templates to show in dropdown + }) + + if (result && result.data) { + setDocumentTemplates(result.data) + } + } catch (error) { + console.error('Error fetching document templates:', error) + } finally { + if (!silent) { + setLoading(false) + } + } + }, + [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,13 +205,54 @@ const DocumentPrintButton = ({ // This could open a print dialog, navigate to a print page, etc. } - // Create dropdown menu items - const menuItems = documentTemplates.map((template) => ({ - key: template._id, - label: template.name, - icon: , - onClick: () => handleTemplateSelect(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: , + 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) { diff --git a/src/components/Dashboard/common/UserNotifierToggle.jsx b/src/components/Dashboard/common/UserNotifierToggle.jsx index b683fd2c..afb5521b 100644 --- a/src/components/Dashboard/common/UserNotifierToggle.jsx +++ b/src/components/Dashboard/common/UserNotifierToggle.jsx @@ -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(() => { - if (!authReady || !objectId || !type) return + const loadNotifierState = useCallback( + async (silent = false) => { + if (!authReady || !objectId || !type) return - const loadNotifierState = async () => { - setInitialLoad(true) + 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 { - setInitialLoad(false) + 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 () => { - setPopoverLoading(true) + 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 { - setPopoverLoading(false) + 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]) - loadAllNotifiers() - }, [authReady, objectId, type, popoverOpen]) + 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'