diff --git a/public/config.js b/public/config.js index b2b9d2087..e05146a4d 100644 --- a/public/config.js +++ b/public/config.js @@ -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 Modern version. This is an early release and some features from the original site are not yet available. Please see the Project Roadmap 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 Modern version. This is an early release and some features from the original site are not yet available. Please see the Project Roadmap for details.", + es: "Está utilizando la nueva versión moderna de Dataverse. Esta es una versión preliminar y algunas funciones del sitio original aún no están disponibles. Consulte la hoja de ruta del proyecto para más detalles." + }, // OIDC provider settings oidc: { clientId: 'test', diff --git a/public/locales/es/account.json b/public/locales/es/account.json index c29270c49..9b20f654e 100644 --- a/public/locales/es/account.json +++ b/public/locales/es/account.json @@ -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 {{userGuideLinkText}}. ¿Quieres probar las funciones de Dataverse? Usa nuestro {{demoServerLinkText}}. Además, revisa tu correo de bienvenida para verificar tu dirección.", + "ingestCompleted": "El dataset {{- datasetDisplayName}} tiene uno o más ficheros tabulares que completaron el proceso de ingestión tabular y están disponibles en formatos de archivo.", + "ingestCompletedWithErrors": "El dataset {{- datasetDisplayName}} tiene uno o más ficheros tabulares disponibles, pero no son compatibles con la ingestión tabular.", + "genericObjectDeleted": "El dataverse, dataset o fichero de esta notificación ha sido eliminado.", + "assignRole": "Se te ha concedido el rol {{roleName}} para {{- objectName}}.", + "assignRoleFileDownloader": "Ahora tienes acceso a todos los ficheros publicados, restringidos y no restringidos, en {{- objectName}}.", + "revokeRole": "Se te ha eliminado de un rol en {{- objectName}}.", + "createCollection": "{{- collectionDisplayName}} se creó en {{- ownerDisplayName}}. Para obtener más información sobre lo que puedes hacer con tu colección, consulta la {{userGuideLinkText}}.", + "createDataset": "{{- datasetDisplayName}} se creó en {{- ownerDisplayName}}. Para obtener más información sobre lo que puedes hacer con tu dataset, consulta la {{userGuideLinkText}}.", + "requestFileAccess": "{{requestorFirstName}} {{requestorLastName}} ({{requestorEmail}}) solicitó acceso a ficheros para el dataset: {{- datasetDisplayName}}.", + "requestedFileAccess": "Has solicitado acceso a ficheros en el dataset: {{- datasetDisplayName}}.", + "grantFileAccess": "Acceso concedido a ficheros en el dataset: {{- datasetDisplayName}}.", + "rejectFileAccess": "Acceso rechazado para los ficheros solicitados en el dataset: {{- datasetDisplayName}}.", + "datasetCreated": "{{- datasetDisplayName}} fue creado en {{- ownerDisplayName}} por {{requestorFirstName}} {{requestorLastName}}.", + "submittedDataset": "{{- datasetDisplayName}} fue enviado para revisión con el fin de publicarse en {{- ownerDisplayName}}. ¡No olvides publicarlo o devolverlo al colaborador, {{requestorFirstName}} {{requestorLastName}} ({{requestorEmail}})!", + "returnedDataset": "{{- datasetDisplayName}} fue devuelto por la persona curadora de {{- ownerDisplayName}}.", + "publishedDataset": "{{- datasetDisplayName}} fue publicado en {{- ownerDisplayName}}.", + "publishFailedPidReg": "{{- datasetDisplayName}} en {{- ownerDisplayName}} 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 {{- datasetDisplayName}} en {{- ownerDisplayName}}. 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 {{- datasetDisplayName}} en {{- ownerDisplayName}} 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 {{- datasetDisplayName}} se ha actualizado a {{currentCurationStatus}}.", + "pidreconciled": "El identificador persistente del dataset {{- datasetDisplayName}} se ha actualizado a {{datasetPersistentId}}.", + "datasetMentioned": "Anuncio recibido: {{type}} recién publicado {{- name}} {{relationship}} dataset {{- datasetDisplayName}}.", + "datasetMentionedGeneric": "Anuncio recibido para el dataset {{- datasetDisplayName}}, información adicional: {{- additionalInfo}}.", + "checksumFail": "Uno o más ficheros de tu carga no superaron la validación de checksum para el dataset {{- datasetDisplayName}}. Vuelve a ejecutar el script de carga. Si el problema persiste, contacta con soporte.", + "importFilesystem": "El dataset {{- datasetDisplayName}} se ha cargado y verificado correctamente.", + "globusUploadCompleted": "La transferencia de Globus al dataset {{- datasetDisplayName}} se completó correctamente. Los ficheros se han cargado y verificado.", + "globusDownloadCompleted": "La transferencia de Globus desde el dataset {{- datasetDisplayName}} se completó correctamente.", + "globusUploadCompletedWithErrors": "La transferencia de Globus al dataset {{- datasetDisplayName}} se completó con errores.", + "globusUploadFailedRemotely": "La transferencia remota de datos entre colecciones de Globus para el dataset {{- datasetDisplayName}} 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 {{- datasetDisplayName}}, pero no pudo agregar los ficheros al dataset localmente.", + "globusDownloadCompletedWithErrors": "La transferencia de Globus desde el dataset {{- datasetDisplayName}} se completó con errores.", + "importChecksum": "{{datasetDisplayName}}, el dataset recibió checksums de ficheros mediante un proceso por lotes." + } } } diff --git a/public/locales/es/file.json b/public/locales/es/file.json index 6fb27e80d..13aa86901 100644 --- a/public/locales/es/file.json +++ b/public/locales/es/file.json @@ -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", diff --git a/public/locales/es/header.json b/public/locales/es/header.json index e12444b98..7a0181163 100644 --- a/public/locales/es/header.json +++ b/public/locales/es/header.json @@ -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", diff --git a/src/config.ts b/src/config.ts index fb4530cef..1b63d2ee0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(), diff --git a/src/notifications/domain/hooks/useNotifications.ts b/src/notifications/domain/hooks/useNotifications.ts index 7afa097d3..4bcb4f39f 100644 --- a/src/notifications/domain/hooks/useNotifications.ts +++ b/src/notifications/domain/hooks/useNotifications.ts @@ -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, diff --git a/src/sections/account/notifications-section/NotificationsSection.module.scss b/src/sections/account/notifications-section/NotificationsSection.module.scss index a0b0b9f65..13b406e45 100644 --- a/src/sections/account/notifications-section/NotificationsSection.module.scss +++ b/src/sections/account/notifications-section/NotificationsSection.module.scss @@ -1,18 +1,36 @@ @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 { @@ -20,8 +38,24 @@ 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; + } +} diff --git a/src/sections/account/notifications-section/NotificationsSection.tsx b/src/sections/account/notifications-section/NotificationsSection.tsx index 7b43fc01d..17563a125 100644 --- a/src/sections/account/notifications-section/NotificationsSection.tsx +++ b/src/sections/account/notifications-section/NotificationsSection.tsx @@ -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( @@ -27,34 +29,46 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe ) const [readIds, setReadIds] = useState([]) + const [deletingIds, setDeletingIds] = useState([]) 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((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 @@ -94,21 +108,22 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe variant="secondary" aria-label={clearAllKeyTranslation} onClick={handleClearAll} - disabled={isLoading}> + disabled={isLoading || deletingIds.length > 0}> {clearAllKeyTranslation} )} {notifications.length > 0 ? ( -
+
{notifications.map((notification) => { const isRead = notification.displayAsRead || readIds.includes(notification.id) + const isDeleting = deletingIds.includes(notification.id) return (
{getTranslatedNotification(notification, t)} @@ -122,6 +137,7 @@ export const NotificationsSection = ({ notificationRepository }: NotificationsSe }} aria-label={t('notifications.dismiss')} data-testid={`dismiss-notification-${notification.id}`} + disabled={isDeleting} />
) diff --git a/src/sections/layout/Layout.tsx b/src/sections/layout/Layout.tsx index 5833d63cc..8fc1d3f07 100644 --- a/src/sections/layout/Layout.tsx +++ b/src/sections/layout/Layout.tsx @@ -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 ( @@ -34,3 +42,29 @@ export function Layout() { ) } + +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] +} diff --git a/tests/component/sections/layout/Layout.spec.tsx b/tests/component/sections/layout/Layout.spec.tsx index 591349ef5..75196c568 100644 --- a/tests/component/sections/layout/Layout.spec.tsx +++ b/tests/component/sections/layout/Layout.spec.tsx @@ -4,6 +4,7 @@ import { FooterMother } from './footer/FooterMother' import { Layout } from '../../../../src/sections/layout/Layout' import { applyTestAppConfig } from '../../../support/bootstrapAppConfig' import type { AppConfig } from '@/config' +import i18next from '@/i18n' describe('Layout', () => { const sandbox: SinonSandbox = createSandbox() @@ -17,6 +18,8 @@ describe('Layout', () => { sandbox.restore() Cypress.env('bannerMessage', defaultBannerMessageEnv) applyTestAppConfig() + cy.clearAllLocalStorage() + cy.wrap(i18next.changeLanguage('en')) }) it('renders the header', () => { @@ -60,4 +63,41 @@ describe('Layout', () => { cy.get('script').should('not.exist') }) }) + + it('renders the banner message for the selected header language', () => { + Cypress.env('bannerMessage', { + en: 'English banner', + es: 'Banner en español moderno' + }) + applyTestAppConfig() + + cy.customMount() + + cy.findByRole('alert').within(() => { + cy.findByText('English', { exact: false }).should('exist') + cy.findByText('Banner en español', { exact: false }).should('not.exist') + }) + + cy.findByRole('button', { name: 'Toggle navigation' }).click() + cy.get('#language-switcher-dropdown').click() + cy.findByText('Español').click() + + cy.findByRole('alert').within(() => { + cy.findByText('Banner en español', { exact: false }).should('exist') + cy.get('strong').should('contain.text', 'moderno') + cy.findByText('English', { exact: false }).should('not.exist') + }) + }) + + it('falls back to the default language banner when the selected language is not configured', () => { + Cypress.env('bannerMessage', { + en: 'Default language banner' + }) + applyTestAppConfig() + + cy.wrap(i18next.changeLanguage('es')) + cy.customMount() + + cy.findByRole('alert').should('contain.text', 'Default language banner') + }) })