Skip to content

Commit 1b43b12

Browse files
authored
Merge pull request #927 from IQSS/904-notifications-refresh
904 notifications refresh
2 parents e0ddce8 + 16339b3 commit 1b43b12

40 files changed

Lines changed: 353 additions & 140 deletions

File tree

packages/design-system/src/lib/components/rich-text-editor/EditorActions.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ export const EditorActions = ({ editor, disabled, locales }: EditorActionsProps)
148148

149149
const handleUndo = () => editor?.chain().focus().undo().run()
150150
const handleRedo = () => editor?.chain().focus().redo().run()
151+
const linkDialogTitle =
152+
locales?.linkDialog?.title ?? richTextEditorDefaultLocales.linkDialog?.title
153+
const imageDialogTitle =
154+
locales?.imageDialog?.title ?? richTextEditorDefaultLocales.imageDialog?.title
151155

152156
return (
153157
<>
@@ -378,11 +382,13 @@ export const EditorActions = ({ editor, disabled, locales }: EditorActionsProps)
378382
</div>
379383

380384
{/* Dialog for pasting a url to the link */}
381-
<Modal show={linkDialog.open} onHide={handleCloseLinkDialog} size="lg">
385+
<Modal
386+
show={linkDialog.open}
387+
onHide={handleCloseLinkDialog}
388+
size="lg"
389+
ariaLabel={linkDialogTitle}>
382390
<Modal.Header>
383-
<Modal.Title>
384-
{locales?.linkDialog?.title ?? richTextEditorDefaultLocales.linkDialog?.title}
385-
</Modal.Title>
391+
<Modal.Title>{linkDialogTitle}</Modal.Title>
386392
</Modal.Header>
387393
<Modal.Body>
388394
<Form.Group controlId="link-url" as={Col}>
@@ -416,11 +422,13 @@ export const EditorActions = ({ editor, disabled, locales }: EditorActionsProps)
416422
</Modal>
417423

418424
{/* Dialog for adding the url and alt text of the image */}
419-
<Modal show={imageDialog.open} onHide={handleCloseImageDialog} size="lg">
425+
<Modal
426+
show={imageDialog.open}
427+
onHide={handleCloseImageDialog}
428+
size="lg"
429+
ariaLabel={imageDialogTitle}>
420430
<Modal.Header>
421-
<Modal.Title>
422-
{locales?.imageDialog?.title ?? richTextEditorDefaultLocales.imageDialog?.title}
423-
</Modal.Title>
431+
<Modal.Title>{imageDialogTitle}</Modal.Title>
424432
</Modal.Header>
425433
<Modal.Body>
426434
<Form.Group controlId="image-url" as={Col}>

packages/design-system/src/lib/stories/modal/Modal.stories.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const meta: Meta<typeof Modal> = {
2929

3030
function DefaultExample(size?: 'sm' | 'lg' | 'xl') {
3131
const [show, setShow] = useState(false)
32+
const modalTitle = 'Modal heading'
3233

3334
const handleClose = () => setShow(false)
3435
const handleShow = () => setShow(true)
@@ -37,9 +38,9 @@ function DefaultExample(size?: 'sm' | 'lg' | 'xl') {
3738
<>
3839
<Button onClick={handleShow}>Launch demo modal</Button>
3940

40-
<Modal show={show} onHide={handleClose} size={size}>
41+
<Modal show={show} onHide={handleClose} size={size} ariaLabel={modalTitle}>
4142
<Modal.Header>
42-
<Modal.Title>Modal heading</Modal.Title>
43+
<Modal.Title>{modalTitle}</Modal.Title>
4344
</Modal.Header>
4445
<Modal.Body>You are reading this text in a modal!</Modal.Body>
4546
<Modal.Footer>

packages/design-system/tests/component/modal/Modal.spec.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { Modal } from '../../../src/lib/components/modal/Modal'
33
describe('Modal', () => {
44
it('renders the modal with header, body and footer', () => {
55
const onHide = cy.stub()
6+
const modalTitle = 'Modal Title'
67
cy.mount(
7-
<Modal show onHide={onHide}>
8+
<Modal show onHide={onHide} ariaLabel={modalTitle}>
89
<Modal.Header>
9-
<Modal.Title>Modal Title</Modal.Title>
10+
<Modal.Title>{modalTitle}</Modal.Title>
1011
</Modal.Header>
1112
<Modal.Body>Modal Body</Modal.Body>
1213
<Modal.Footer>
@@ -25,10 +26,11 @@ describe('Modal', () => {
2526

2627
it('calls onHide when the close button is clicked', () => {
2728
const onHide = cy.stub().as('onHide')
29+
const modalTitle = 'Modal Title'
2830
cy.mount(
29-
<Modal show onHide={onHide}>
31+
<Modal show onHide={onHide} ariaLabel={modalTitle}>
3032
<Modal.Header>
31-
<Modal.Title>Modal Title</Modal.Title>
33+
<Modal.Title>{modalTitle}</Modal.Title>
3234
</Modal.Header>
3335
<Modal.Body>Modal Body</Modal.Body>
3436
</Modal>
@@ -40,10 +42,11 @@ describe('Modal', () => {
4042

4143
it('renders the modal with a custom size', () => {
4244
const onHide = cy.stub()
45+
const modalTitle = 'Modal Title'
4346
cy.mount(
44-
<Modal show onHide={onHide} size="sm">
47+
<Modal show onHide={onHide} size="sm" ariaLabel={modalTitle}>
4548
<Modal.Header>
46-
<Modal.Title>Modal Title</Modal.Title>
49+
<Modal.Title>{modalTitle}</Modal.Title>
4750
</Modal.Header>
4851
<Modal.Body>Modal Body</Modal.Body>
4952
</Modal>

src/notifications/domain/hooks/useNeedsUpdate.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,5 @@
1-
// hooks/useNeedsUpdate.ts
21
import { useSyncExternalStore } from 'react'
3-
type Listener = () => void
4-
5-
class NeedsUpdateStore {
6-
private needsUpdate = false
7-
private listeners = new Set<Listener>()
8-
9-
getSnapshot = () => this.needsUpdate
10-
11-
subscribe = (callback: Listener) => {
12-
this.listeners.add(callback)
13-
return () => this.listeners.delete(callback)
14-
}
15-
16-
setNeedsUpdate(value: boolean) {
17-
if (this.needsUpdate !== value) {
18-
this.needsUpdate = value
19-
this.emit()
20-
}
21-
}
22-
23-
private emit() {
24-
this.listeners.forEach((listener) => listener())
25-
}
26-
}
27-
const needsUpdateStore = new NeedsUpdateStore()
2+
import { needsUpdateStore } from './needsUpdateStore'
283

294
export function useNeedsUpdate() {
305
return useSyncExternalStore(needsUpdateStore.subscribe, needsUpdateStore.getSnapshot)

src/notifications/domain/hooks/useNotifications.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Notification } from '@/notifications/domain/models/Notification'
44
import { NotificationRepository } from '@/notifications/domain/repositories/NotificationRepository'
55
import { NotificationsPaginationInfo } from '@/notifications/domain/models/NotificationsPaginationInfo'
66
import { getAllNotificationsByUser } from '@/notifications/domain/useCases/getAllNotificationsByUser'
7+
import { needsUpdateStore } from './needsUpdateStore'
78

89
const POLLING_NOTIFICATIONS_INTERVAL_TIME = 30_000
910

@@ -67,6 +68,7 @@ export function useNotifications(
6768
try {
6869
await Promise.all(ids.map((id) => repository.markNotificationAsRead(id)))
6970
setError(null)
71+
needsUpdateStore.setNeedsUpdate(true)
7072
} catch (err) {
7173
const message = err instanceof Error ? err.message : 'Failed to mark as read'
7274
setError(message)
@@ -78,6 +80,7 @@ export function useNotifications(
7880
try {
7981
await Promise.all(ids.map((id) => repository.deleteNotification(id)))
8082
setError(null)
83+
needsUpdateStore.setNeedsUpdate(true)
8184
} catch (err) {
8285
const message = err instanceof Error ? err.message : 'Failed to delete notifications'
8386
setError(message)
Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,78 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { needsUpdateStore } from './needsUpdateStore'
33
import { NotificationRepository } from '@/notifications/domain/repositories/NotificationRepository'
44
import { User } from '@/users/domain/models/User'
55
import { useNeedsUpdate } from '@/notifications/domain/hooks/useNeedsUpdate'
66
import { getUnreadNotificationsCount } from '@/notifications/domain/useCases/getUnreadNotificationsCount'
77

88
const POLLING_INTERVAL = 30000 // 30 seconds
9+
const INVALIDATION_RETRY_INTERVAL = 1000
10+
const INVALIDATION_RETRY_ATTEMPTS = 6
911
export function useUnreadCount(user: User, notificationRepository: NotificationRepository) {
1012
const [unreadCount, setUnreadCount] = useState(0)
13+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
14+
const refreshCycleRef = useRef(0)
1115

1216
const needsUpdate = useNeedsUpdate()
13-
const fetchUnread = useCallback(async () => {
14-
if (user) {
15-
const count = await getUnreadNotificationsCount(notificationRepository)
16-
setUnreadCount(count)
17+
18+
const clearRetryTimeout = useCallback(() => {
19+
if (retryTimeoutRef.current) {
20+
clearTimeout(retryTimeoutRef.current)
21+
retryTimeoutRef.current = null
1722
}
18-
needsUpdateStore.setNeedsUpdate(false)
19-
}, [user, notificationRepository])
23+
}, [])
24+
25+
const fetchUnread = useCallback(
26+
async (refreshCycleId?: number) => {
27+
if (user) {
28+
const count = await getUnreadNotificationsCount(notificationRepository)
29+
if (refreshCycleId === undefined || refreshCycleRef.current === refreshCycleId) {
30+
setUnreadCount(count)
31+
}
32+
}
33+
},
34+
[user, notificationRepository]
35+
)
36+
2037
useEffect(() => {
2138
if (needsUpdate) {
22-
void fetchUnread()
39+
const refreshCycleId = refreshCycleRef.current + 1
40+
refreshCycleRef.current = refreshCycleId
41+
clearRetryTimeout()
42+
needsUpdateStore.setNeedsUpdate(false)
43+
44+
const runRefreshCycle = async (attempt: number) => {
45+
await fetchUnread(refreshCycleId)
46+
47+
if (refreshCycleRef.current !== refreshCycleId) {
48+
return
49+
}
50+
51+
if (attempt < INVALIDATION_RETRY_ATTEMPTS - 1) {
52+
retryTimeoutRef.current = setTimeout(() => {
53+
void runRefreshCycle(attempt + 1)
54+
}, INVALIDATION_RETRY_INTERVAL)
55+
}
56+
}
57+
58+
void runRefreshCycle(0)
2359
}
24-
}, [needsUpdate, fetchUnread, notificationRepository])
60+
}, [needsUpdate, fetchUnread, clearRetryTimeout])
61+
2562
// Polling trigger
2663
useEffect(() => {
2764
const interval = setInterval(() => {
2865
void fetchUnread()
2966
}, POLLING_INTERVAL)
3067

3168
return () => clearInterval(interval)
32-
}, [fetchUnread, notificationRepository])
69+
}, [fetchUnread])
3370

3471
useEffect(() => {
3572
void fetchUnread() // run once when the component mounts
3673
}, [fetchUnread])
3774

75+
useEffect(() => clearRetryTimeout, [clearRetryTimeout])
76+
3877
return unreadCount
3978
}

src/sections/account/notifications-section/NotificationsSection.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { Alert, Button, CloseButton, Stack } from '@iqss/dataverse-design-system'
44
import { getTranslatedNotification } from '@/sections/account/notifications-section/NotificationsHelper'
5-
import { needsUpdateStore } from '@/notifications/domain/hooks/needsUpdateStore'
65
import { useNotifications } from '@/notifications/domain/hooks/useNotifications'
76
import { NotificationRepository } from '@/notifications/domain/repositories/NotificationRepository'
87
import { NotificationsPaginationInfo } from '@/notifications/domain/models/NotificationsPaginationInfo'
@@ -39,7 +38,6 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe
3938
await markAsRead(unreadIds)
4039
setReadIds((prev) => [...prev, ...unreadIds])
4140
await refetch()
42-
needsUpdateStore.setNeedsUpdate(true)
4341
})()
4442
}, 2000)
4543
return () => clearTimeout(timer)
@@ -86,7 +84,9 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe
8684
direction="horizontal"
8785
gap={2}
8886
style={{ width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
89-
<div>{t('notifications.displayingNotifications', { start, end, total })}</div>
87+
{notifications.length > 0 && (
88+
<div>{t('notifications.displayingNotifications', { start, end, total })}</div>
89+
)}
9090

9191
{notifications.length > 0 && (
9292
<Button

src/sections/collection/link-collection-dropdown/LinkCollectionDropdown.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export const LinkCollectionDropdown = ({
105105

106106
<Modal
107107
show={showModal}
108+
ariaLabel={'Link Collection Modal'}
108109
onHide={isLinkingCollection ? () => {} : handleClose}
109110
centered
110111
size="lg">

src/sections/collection/publish-collection/PublishCollectionModal.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function PublishCollectionModal({
2525
}: PublishCollectionModalProps) {
2626
const { t: tShared } = useTranslation('shared')
2727
const { t: tCollection } = useTranslation('collection')
28+
const modalTitle = tCollection('publish.title')
2829
const navigate = useNavigate()
2930

3031
const { submissionStatus, submitPublish, publishError } = usePublishCollection(
@@ -41,9 +42,9 @@ export function PublishCollectionModal({
4142
}
4243

4344
return (
44-
<Modal show={show} onHide={handleClose} size="lg">
45+
<Modal show={show} onHide={handleClose} size="lg" ariaLabel={modalTitle}>
4546
<Modal.Header>
46-
<Modal.Title>{tCollection('publish.title')}</Modal.Title>
47+
<Modal.Title>{modalTitle}</Modal.Title>
4748
</Modal.Header>
4849
<Modal.Body>
4950
<Stack direction="vertical">

src/sections/dataset/dataset-action-buttons/link-and-unlink-actions/link-dataset-button/LinkDatasetButton.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,12 @@ export function LinkDatasetButton({
112112
{t('datasetActionButtons.linkDataset.title')}
113113
</Button>
114114

115-
<Modal show={showModal} onHide={isLinkingDataset ? () => {} : handleClose} centered size="lg">
115+
<Modal
116+
ariaLabel={'Link Dataset Button'}
117+
show={showModal}
118+
onHide={isLinkingDataset ? () => {} : handleClose}
119+
centered
120+
size="lg">
116121
<Modal.Header>
117122
<Modal.Title>{t('datasetActionButtons.linkDataset.title')}</Modal.Title>
118123
</Modal.Header>

0 commit comments

Comments
 (0)