Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions public/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ window.__APP_CONFIG__ = {
// Base URL of your Dataverse backend
backendUrl: 'http://localhost:8000',
// Optional banner shown at the top of the app when set. Basic HTML markup is supported.
bannerMessage:
"You are using the new Dataverse <strong>Modern version</strong>. This is an early release and some features from the original site are not yet available. Please see the <a href='https://dataverse.harvard.edu/modern/featured-item/harvard/1'>Project Roadmap</a> for details.",
// Use a string for one message across all languages, or map language codes to localized messages.
bannerMessage: {
en: "You are using the new Dataverse <strong>Modern version</strong>. This is an early release and some features from the original site are not yet available. Please see the <a href='https://dataverse.harvard.edu/modern/featured-item/harvard/1'>Project Roadmap</a> for details.",
es: "Está utilizando la nueva <strong>versión moderna</strong> de Dataverse. Esta es una versión preliminar y algunas funciones del sitio original aún no están disponibles. Consulte la <a href='https://dataverse.harvard.edu/modern/featured-item/harvard/1'>hoja de ruta del proyecto</a> para más detalles."
},
// OIDC provider settings
oidc: {
clientId: 'test',
Expand Down
45 changes: 45 additions & 0 deletions public/locales/es/account.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,50 @@
"datasetAndFile": "datasets o ficheros"
}
}
},
"notifications": {
"title": "Notificaciones",
"userGuideLinkText": "Guía de usuario",
"demoServerLinkText": "Sitio de demostración",
"clearAllNotifications": "Borrar todas las notificaciones",
"clearAllOnThisPage": "Borrar todas las notificaciones de esta página",
"displayingNotifications": "Mostrando {{start}} - {{end}} de {{total}} notificaciones",
"dismiss": "Descartar",
"noNotifications": "No hay notificaciones disponibles.",
"notification": {
"createAcc": "¡Te damos la bienvenida a {{installationBrandName}}! Comienza agregando o buscando datos. ¿Tienes preguntas? Consulta la <userGuideLink>{{userGuideLinkText}}</userGuideLink>. ¿Quieres probar las funciones de Dataverse? Usa nuestro <demoLink>{{demoServerLinkText}}</demoLink>. Además, revisa tu correo de bienvenida para verificar tu dirección.",
"ingestCompleted": "El dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> tiene uno o más ficheros tabulares que completaron el <userGuideLink>proceso de ingestión tabular</userGuideLink> y están disponibles en formatos de archivo.",
"ingestCompletedWithErrors": "El dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> tiene uno o más ficheros tabulares disponibles, pero no son compatibles con la <b>ingestión tabular</b>.",
"genericObjectDeleted": "El dataverse, dataset o fichero de esta notificación ha sido eliminado.",
"assignRole": "Se te ha concedido el rol {{roleName}} para <objectLink>{{- objectName}}</objectLink>.",
"assignRoleFileDownloader": "Ahora tienes acceso a todos los ficheros publicados, restringidos y no restringidos, en <objectLink>{{- objectName}}</objectLink>.",
"revokeRole": "Se te ha eliminado de un rol en <objectLink>{{- objectName}}</objectLink>.",
"createCollection": "<collectionLink>{{- collectionDisplayName}}</collectionLink> se creó en <ownerLink>{{- ownerDisplayName}}</ownerLink>. Para obtener más información sobre lo que puedes hacer con tu colección, consulta la <userGuideLink>{{userGuideLinkText}}</userGuideLink>.",
"createDataset": "<datasetLink>{{- datasetDisplayName}}</datasetLink> se creó en <ownerLink>{{- ownerDisplayName}}</ownerLink>. Para obtener más información sobre lo que puedes hacer con tu dataset, consulta la <userGuideLink>{{userGuideLinkText}}</userGuideLink>.",
"requestFileAccess": "{{requestorFirstName}} {{requestorLastName}} ({{requestorEmail}}) solicitó acceso a ficheros para el dataset: <datasetLink>{{- datasetDisplayName}}</datasetLink>.",
"requestedFileAccess": "Has solicitado acceso a ficheros en el dataset: <datasetLink>{{- datasetDisplayName}}</datasetLink>.",
"grantFileAccess": "Acceso concedido a ficheros en el dataset: <datasetLink>{{- datasetDisplayName}}</datasetLink>.",
"rejectFileAccess": "Acceso rechazado para los ficheros solicitados en el dataset: <datasetLink>{{- datasetDisplayName}}</datasetLink>.",
"datasetCreated": "<datasetLink>{{- datasetDisplayName}}</datasetLink> fue creado en <ownerLink>{{- ownerDisplayName}}</ownerLink> por {{requestorFirstName}} {{requestorLastName}}.",
"submittedDataset": "<datasetLink>{{- datasetDisplayName}}</datasetLink> fue enviado para revisión con el fin de publicarse en <ownerLink>{{- ownerDisplayName}}</ownerLink>. ¡No olvides publicarlo o devolverlo al colaborador, {{requestorFirstName}} {{requestorLastName}} ({{requestorEmail}})!",
"returnedDataset": "<datasetLink>{{- datasetDisplayName}}</datasetLink> fue devuelto por la persona curadora de <ownerLink>{{- ownerDisplayName}}</ownerLink>.",
"publishedDataset": "<datasetLink>{{- datasetDisplayName}}</datasetLink> fue publicado en <ownerLink>{{- ownerDisplayName}}</ownerLink>.",
"publishFailedPidReg": "<datasetLink>{{- datasetDisplayName}}</datasetLink> en <ownerLink>{{- ownerDisplayName}}</ownerLink> no pudo publicarse debido a un error al registrar o actualizar el identificador global del dataset o de uno de sus ficheros. Contacta con soporte si esto sigue ocurriendo.",
"workflowFailure": "Ha fallado un flujo de trabajo externo ejecutado en <datasetLink>{{- datasetDisplayName}}</datasetLink> en <ownerLink>{{- ownerDisplayName}}</ownerLink>. Revisa tu correo electrónico o consulta la página del dataset, que puede contener detalles adicionales. Contacta con soporte si esto sigue ocurriendo.",
"workflowSuccess": "Un flujo de trabajo externo ejecutado en <datasetLink>{{- datasetDisplayName}}</datasetLink> en <ownerLink>{{- ownerDisplayName}}</ownerLink> se completó correctamente. Revisa tu correo electrónico o consulta la página del dataset, que puede contener detalles adicionales.",
"statusUpdated": "El estado del dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se ha actualizado a {{currentCurationStatus}}.",
"pidreconciled": "El identificador persistente del dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se ha actualizado a {{datasetPersistentId}}.",
"datasetMentioned": "Anuncio recibido: {{type}} recién publicado <relatedLink>{{- name}}</relatedLink> {{relationship}} dataset <datasetLink>{{- datasetDisplayName}}</datasetLink>.",
"datasetMentionedGeneric": "Anuncio recibido para el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink>, información adicional: {{- additionalInfo}}.",
"checksumFail": "Uno o más ficheros de tu carga no superaron la validación de checksum para el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink>. Vuelve a ejecutar el script de carga. Si el problema persiste, contacta con soporte.",
"importFilesystem": "El dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se ha cargado y verificado correctamente.",
"globusUploadCompleted": "La transferencia de Globus al dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se completó correctamente. Los ficheros se han cargado y verificado.",
"globusDownloadCompleted": "La transferencia de Globus desde el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se completó correctamente.",
"globusUploadCompletedWithErrors": "La transferencia de Globus al dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se completó con errores.",
"globusUploadFailedRemotely": "La transferencia remota de datos entre colecciones de Globus para el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> falló, según lo informado por la API de Globus.",
"globusUploadFailedLocally": "Dataverse recibió una confirmación de una transferencia de datos de Globus correcta para el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink>, pero no pudo agregar los ficheros al dataset localmente.",
"globusDownloadCompletedWithErrors": "La transferencia de Globus desde el dataset <datasetLink>{{- datasetDisplayName}}</datasetLink> se completó con errores.",
"importChecksum": "<datasetLink>{{datasetDisplayName}}</datasetLink>, el dataset recibió checksums de ficheros mediante un proceso por lotes."
}
}
}
1 change: 1 addition & 0 deletions public/locales/es/file.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"summary": "Resumen",
"contributors": "Colaboradores",
"noChange": "No hay cambios asociados con esta versión.",
"noVersions": "El archivo no está incluido en esta versión.",
"file": "archivo",
"fileChanged": "[Fichero {{name}}]",
"access": "Acceso al archivo",
Expand Down
1 change: 1 addition & 0 deletions public/locales/es/header.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"brandLogoImage": "Logotipo de Dataverse",
"navigation": {
"addData": "Agregar datos",
"notifications": "Notificaciones",
"newCollection": "Nueva colección",
"newDataset": "Nuevo dataset",
"accountInfo": "Información de la cuenta",
Expand Down
4 changes: 3 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ declare global {

let CONFIG: AppConfig | undefined

const BannerMessageSchema = z.union([z.string(), z.record(z.string(), z.string())])

const AppConfigSchema = z.object({
backendUrl: z.url(),
bannerMessage: z.string().optional(),
bannerMessage: BannerMessageSchema.optional(),
oidc: z.object({
clientId: z.string(),
authorizationEndpoint: z.url(),
Expand Down
58 changes: 34 additions & 24 deletions src/notifications/domain/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,31 +61,41 @@ export function useNotifications(
return () => clearInterval(interval)
}, [fetchNotifications, user])

const markAsRead = async (ids: number[]) => {
setNotifications((prev) =>
prev.map((n) => (ids.includes(n.id) ? { ...n, displayAsRead: true } : n))
)
try {
await Promise.all(ids.map((id) => repository.markNotificationAsRead(id)))
setError(null)
needsUpdateStore.setNeedsUpdate(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to mark as read'
setError(message)
}
}
const markAsRead = useCallback(
async (ids: number[]) => {
if (ids.length === 0) return

const deleteMany = async (ids: number[]) => {
setNotifications((prev) => prev.filter((n) => !ids.includes(n.id)))
try {
await Promise.all(ids.map((id) => repository.deleteNotification(id)))
setError(null)
needsUpdateStore.setNeedsUpdate(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete notifications'
setError(message)
}
}
setNotifications((prev) =>
prev.map((n) => (ids.includes(n.id) ? { ...n, displayAsRead: true } : n))
)
try {
await Promise.all(ids.map((id) => repository.markNotificationAsRead(id)))
setError(null)
needsUpdateStore.setNeedsUpdate(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to mark as read'
setError(message)
}
},
[repository]
)

const deleteMany = useCallback(
async (ids: number[]) => {
if (ids.length === 0) return

setNotifications((prev) => prev.filter((n) => !ids.includes(n.id)))
try {
await Promise.all(ids.map((id) => repository.deleteNotification(id)))
setError(null)
needsUpdateStore.setNeedsUpdate(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete notifications'
setError(message)
}
},
[repository]
)

return {
notifications,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
@use 'sass:color';
@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module';

.notifications-list {
display: flex;
flex-direction: column;
}

.notification-item {
display: flex;
gap: 8px;
justify-content: space-between;
max-height: 16rem;
overflow: hidden;
padding: 0.75rem;
background-color: color.adjust($dv-secondary-color, $lightness: 8%);
border-radius: 6px;
transition: background 0.3s;
color: inherit;
box-shadow: inset 0 0 0 1px transparent;
transition: background-color 0.7s ease, box-shadow 0.7s ease, color 0.7s ease,
margin-bottom 0.25s ease, max-height 0.25s ease, opacity 0.2s ease, padding-bottom 0.25s ease,
padding-top 0.25s ease, transform 0.2s ease;
will-change: background-color, box-shadow, color, margin-bottom, max-height, opacity, padding,
transform;
}

.notifications-list .notification-item {
margin-bottom: 0.5rem;
}

.unread {
background-color: #fffbe6;
box-shadow: inset 0 0 0 1px #f4e6a2;
}

.read {
background-color: #f5f5f5;
color: #888;
}

.notifications-list .deleting {
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
pointer-events: none;
transform: translateX(8px);
}

.timestamp {
margin-left: 8px;
color: #888;
font-size: 0.85em;
}

@media (prefers-reduced-motion: reduce) {
.notification-item {
transition: none;
}
}
44 changes: 30 additions & 14 deletions src/sections/account/notifications-section/NotificationsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface NotificationsSectionProps {
notificationRepository: NotificationRepository
}

const DELETE_TRANSITION_DURATION_MS = 250

export const NotificationsSection = ({ notificationRepository }: NotificationsSectionProps) => {
const { t } = useTranslation('account')
const [paginationInfo, setPaginationInfo] = useState<NotificationsPaginationInfo>(
Expand All @@ -27,34 +29,46 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe
)

const [readIds, setReadIds] = useState<number[]>([])
const [deletingIds, setDeletingIds] = useState<number[]>([])

useEffect(() => {
const unreadIds = notifications
.filter((n) => !n.displayAsRead && !readIds.includes(n.id))
.filter((n) => !n.displayAsRead && !readIds.includes(n.id) && !deletingIds.includes(n.id))
.map((n) => n.id)
if (unreadIds.length > 0) {
const timer = setTimeout(() => {
void (async () => {
await markAsRead(unreadIds)
setReadIds((prev) => [...prev, ...unreadIds])
await refetch()
setReadIds((prev) => Array.from(new Set([...prev, ...unreadIds])))
await refetch(true)
})()
}, 2000)
return () => clearTimeout(timer)
}
}, [notifications, readIds, markAsRead, refetch])
}, [notifications, readIds, deletingIds, markAsRead, refetch])

const removeNotifications = async (ids: number[]) => {
const idsToDelete = Array.from(new Set(ids)).filter((id) => !deletingIds.includes(id))

if (idsToDelete.length === 0) return

setDeletingIds((prev) => Array.from(new Set([...prev, ...idsToDelete])))

await new Promise<void>((resolve) => {
window.setTimeout(resolve, DELETE_TRANSITION_DURATION_MS)
})

await deleteMany(idsToDelete)
setDeletingIds((prev) => prev.filter((id) => !idsToDelete.includes(id)))
await refetch(true)
}

const handleDelete = async (id: number) => {
await deleteMany([id])
await refetch()
await removeNotifications([id])
}

const handleClearAll = async () => {
const ids = notifications.map((n) => n.id)
if (ids.length > 0) {
await deleteMany(ids)
await refetch()
}
await removeNotifications(notifications.map((n) => n.id))
}

if (isLoading) return <NotificationSkeleton rows={5} />
Expand Down Expand Up @@ -94,21 +108,22 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe
variant="secondary"
aria-label={clearAllKeyTranslation}
onClick={handleClearAll}
disabled={isLoading}>
disabled={isLoading || deletingIds.length > 0}>
{clearAllKeyTranslation}
</Button>
)}
</Stack>

{notifications.length > 0 ? (
<div className="d-flex flex-column gap-2">
<div className={styles['notifications-list']}>
{notifications.map((notification) => {
const isRead = notification.displayAsRead || readIds.includes(notification.id)
const isDeleting = deletingIds.includes(notification.id)
return (
<div
className={`${styles['notification-item']} ${
isRead ? styles['read'] : styles['unread']
}`}
} ${isDeleting ? styles['deleting'] : ''}`}
key={notification.id}>
<div>
{getTranslatedNotification(notification, t)}
Expand All @@ -122,6 +137,7 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe
}}
aria-label={t('notifications.dismiss')}
data-testid={`dismiss-notification-${notification.id}`}
disabled={isDeleting}
/>
</div>
)
Expand Down
40 changes: 37 additions & 3 deletions src/sections/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import DOMPurify from 'dompurify'
import { Outlet } from 'react-router-dom'
import { Container } from '@iqss/dataverse-design-system'
import { useTranslation } from 'react-i18next'
import styles from './Layout.module.scss'
import { FooterFactory } from './footer/FooterFactory'
import TopBarProgressIndicator from './topbar-progress-indicator/TopbarProgressIndicator'
import { HeaderFactory } from './header/HeaderFactory'
import { HistoryTrackerProvider } from '@/router/HistoryTrackerProvider'
import { requireAppConfig } from '@/config'
import type { AppConfig } from '@/config'

export function Layout() {
const { bannerMessage } = requireAppConfig()
const sanitizedBannerMessage = bannerMessage
? DOMPurify.sanitize(bannerMessage, { USE_PROFILES: { html: true } })
const { i18n } = useTranslation()
const { bannerMessage, defaultLanguage } = requireAppConfig()
const localizedBannerMessage = getLocalizedBannerMessage(
bannerMessage,
i18n.resolvedLanguage ?? i18n.language,
defaultLanguage
)
const sanitizedBannerMessage = localizedBannerMessage
? DOMPurify.sanitize(localizedBannerMessage, { USE_PROFILES: { html: true } })
: null

return (
Expand All @@ -34,3 +42,29 @@ export function Layout() {
</HistoryTrackerProvider>
)
}

function getLocalizedBannerMessage(
bannerMessage: AppConfig['bannerMessage'],
selectedLanguage: string | undefined,
defaultLanguage: string
): string | undefined {
if (!bannerMessage || typeof bannerMessage === 'string') return bannerMessage

const normalizedMessages = Object.fromEntries(
Object.entries(bannerMessage).map(([language, message]) => [language.toLowerCase(), message])
)
const selectedLanguageCode = selectedLanguage?.toLowerCase()
const defaultLanguageCode = defaultLanguage.toLowerCase()
const candidateLanguages = [
selectedLanguageCode,
selectedLanguageCode?.split('-')[0],
defaultLanguageCode,
defaultLanguageCode.split('-')[0]
].filter((language): language is string => Boolean(language))

for (const language of candidateLanguages) {
if (normalizedMessages[language]) return normalizedMessages[language]
}

return Object.values(bannerMessage)[0]
}
Loading
Loading