diff --git a/package-lock.json b/package-lock.json index cf249ad1d..ff8225b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.85", + "@iqss/dataverse-client-javascript": "2.1.0-pr429.f4e6bd4", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -1954,9 +1954,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.85", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.85/9f7d8be5c1fbe022c11fcc6d956bbf1cc4821776", - "integrity": "sha512-2aEA0MdkhFjugxImJ3a334jlVbwmNMDVsFXnwPutbbkqn89UMXClMxADkqlmLQ3/4dOMtOrdVDitxYsYmYZ6RQ==", + "version": "2.1.0-pr429.f4e6bd4", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.1.0-pr429.f4e6bd4/cb8fae04e50118b9a4efb59bb2c091db2c301b0e", + "integrity": "sha512-lHSpbOcINfNGsb4qB5XIy0r9rab+oP0SF3yybiQ8U+zZOVrS3hoIdMNpy+pjq8QQOlSlN2iW3Fn1mcWy7LcSGw==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 3b8b27edf..787d5ae2b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.85", + "@iqss/dataverse-client-javascript": "2.1.0-pr429.f4e6bd4", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/public/locales/en/dataset.json b/public/locales/en/dataset.json index 47c91d018..1ee0cf33c 100644 --- a/public/locales/en/dataset.json +++ b/public/locales/en/dataset.json @@ -183,6 +183,11 @@ "requestAccessTip": "If checked, users can request access to the restricted files in this dataset.", "requestAccessTrue": "Users may request access to files.", "requestAccessFalse": "Users may not request access to files.", + "guestbookTitle": "Guestbook", + "guestbookTip": "User information (i.e., name, email, institution, and position) will be collected when files are downloaded.", + "guestbookDescription": "The following guestbook will prompt a user to provide additional information when downloading a file.", + "noGuestbookAssigned": "No guestbook is assigned to this dataset so users will not be prompted to provide any information when downloading files. To learn more about guestbooks, visit the Dataset Guestbook section of the User Guide.", + "guestbookPreviewButton": "Preview Guestbook", "restrictedFilesTip": "The number of restricted files in this dataset.", "dataAccessPlaceTip": "If the data is not only in Dataverse, list the location(s) where the data are currently stored.", "originalArchiveTip": "Archive from which the data was obtained.", @@ -347,7 +352,7 @@ "tabs": { "datasetTerms": "Dataset Terms", "restrictedFilesTerms": "Restricted Files + Terms of Access", - "guestBook": "GuestBook" + "guestbook": "Guestbook" }, "datasetTerms": { "title": "Dataset Terms", @@ -361,10 +366,10 @@ "title": "Restricted Files + Terms of Access", "description": "Set up access restrictions and terms for restricted files in this dataset." }, - "guestBook": { - "title": "GuestBook", - "description": "Select a guestbook to have a user provide additional information when downloading a file.", - "testGuestbook": "Test Guestbook", + "guestbook": { + "title": "Guestbook", + "description": "Select a guestbook to have a user provide additional information when downloading a file. To learn more about guestbooks, visit the Dataset Guestbook section of the User Guide.", + "noGuestbooksEnabled": "There are no guestbooks enabled in {{collectionName}}. To create a guestbook, return to {{collectionName}}, click the \"Edit\" button and select the \"Dataset Guestbooks\" option.", "previewButton": "Preview Guestbook" }, "unsavedChangesModal": { @@ -373,6 +378,7 @@ "stay": "Stay on this page", "leave": "Leave without saving" }, + "defaultGuestbookUpdateError": "An error occurred while updating the dataset guestbook. Please try again.", "defaultLicenseUpdateError": "An error occurred while updating the dataset license. Please try again.", "defaultTermsOfAccessUpdateError": "An error occurred while updating the dataset terms of access. Please try again." } diff --git a/public/locales/en/file.json b/public/locales/en/file.json index c08a349a8..5f38bb1a2 100644 --- a/public/locales/en/file.json +++ b/public/locales/en/file.json @@ -91,6 +91,18 @@ "helpText": "Share this file on your favorite social media networks." } }, + "actions": { + "optionsMenu": { + "guestbookAppliedModal": { + "submitError": "Something went wrong submitting guestbook responses. Try again later.", + "downloadError": "Something went wrong downloading the file. Try again later.", + "validation": { + "required": "This field is required.", + "invalidEmail": "Please enter a valid email address." + } + } + } + }, "deleteFileModal": { "title": "Delete File", "message": "The file will be deleted after you click on the Delete button.", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index b2c5aa8de..abe7f02cd 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -80,6 +80,18 @@ "title": "File Options", "headers": { "editOptions": "Edit Options" + }, + "guestbookAppliedModal": { + "title": "Dataset Guestbook", + "message": "A guestbook is assigned to this dataset. Users will be prompted to provide additional information when downloading files.", + "close": "Close", + "additionalQuestions": "Additional Questions", + "submitError": "Something went wrong submitting guestbook responses. Try again later.", + "downloadError": "Something went wrong downloading the file. Try again later.", + "validation": { + "required": "This field is required.", + "invalidEmail": "Please enter a valid email address." + } } }, "alreadyDeletedAlert": { diff --git a/public/locales/en/guestbooks.json b/public/locales/en/guestbooks.json new file mode 100644 index 000000000..5670d8bc9 --- /dev/null +++ b/public/locales/en/guestbooks.json @@ -0,0 +1,33 @@ +{ + "title": "Dataset Guestbooks", + "errors": { + "getGuestbook": "Something went wrong getting the guestbook. Try again later." + }, + "preview": { + "title": "Preview Guestbook", + "description": "Upon downloading files the guestbook asks for the following information.", + "guestbookNameLabel": "Guestbook Name", + "guestbookDataLabel": "Collected Data", + "guestbookDataTip": "User data collected by the guestbook.", + "collectedDataLabel": "Collected Data", + "collectedDataTip": "User data collected by the guestbook.", + "accountInformation": "Account Information", + "customQuestionsLabel": "Custom Questions", + "required": "Required", + "optional": "Optional" + }, + "create": { + "fields": { + "dataCollected": { + "label": "Data Collected", + "help": "Dataverse account information that will be collected when a user downloads a file. Check the ones that will be required.", + "options": { + "name": "Name", + "email": "Email", + "institution": "Institution", + "position": "Position" + } + } + } + } +} diff --git a/public/locales/es/dataset.json b/public/locales/es/dataset.json index 30ad2dc4e..4dbfb4ec8 100644 --- a/public/locales/es/dataset.json +++ b/public/locales/es/dataset.json @@ -175,6 +175,7 @@ "requestAccessTip": "Si está marcado, los usuarios pueden solicitar acceso a los ficheros restringidos en este dataset.", "requestAccessTrue": "Los usuarios pueden solicitar acceso a los ficheros.", "requestAccessFalse": "Los usuarios no pueden solicitar acceso a los ficheros.", + "noGuestbookAssigned": "No hay un libro de visitas asignado a este dataset, por lo que no se pedirá a los usuarios que proporcionen información al descargar archivos. Para obtener más información sobre libros de visitas, visita la sección Dataset Guestbook de la Guía del usuario.", "restrictedFilesTip": "El número de ficheros restringidos en este dataset.", "dataAccessPlaceTip": "Si los datos no están solo en Dataverse, enumera la(s) ubicación(es) donde los datos están actualmente almacenados.", "originalArchiveTip": "Fichero del cual se obtuvieron los datos.", diff --git a/public/locales/es/file.json b/public/locales/es/file.json index 1796d6c05..53b6c5340 100644 --- a/public/locales/es/file.json +++ b/public/locales/es/file.json @@ -91,6 +91,18 @@ "helpText": "Comparte este fichero en tus redes sociales favoritas." } }, + "actions": { + "optionsMenu": { + "guestbookAppliedModal": { + "submitError": "Algo salió mal al enviar las respuestas del libro de visitas. Inténtalo de nuevo más tarde.", + "downloadError": "Algo salió mal al descargar el archivo. Inténtalo de nuevo más tarde.", + "validation": { + "required": "Este campo es obligatorio.", + "invalidEmail": "Por favor, ingresa un correo electrónico válido." + } + } + } + }, "deleteFileModal": { "title": "Eliminar archivo", "message": "El fichero se eliminará después de hacer clic en el botón Eliminar.", diff --git a/public/locales/es/files.json b/public/locales/es/files.json index 164dbdc9b..f6f674c25 100644 --- a/public/locales/es/files.json +++ b/public/locales/es/files.json @@ -80,6 +80,18 @@ "title": "Opciones de archivo", "headers": { "editOptions": "Opciones de edición" + }, + "guestbookAppliedModal": { + "title": "Libro de visitas del dataset", + "message": "Hay un libro de visitas asignado a este dataset. Se pedirá a los usuarios que proporcionen información adicional al descargar archivos.", + "close": "Cerrar", + "additionalQuestions": "Preguntas adicionales", + "submitError": "Algo salió mal al enviar las respuestas del libro de visitas. Inténtalo de nuevo más tarde.", + "downloadError": "Algo salió mal al descargar el archivo. Inténtalo de nuevo más tarde.", + "validation": { + "required": "Este campo es obligatorio.", + "invalidEmail": "Por favor, ingresa un correo electrónico válido." + } } }, "alreadyDeletedAlert": { diff --git a/src/access/domain/repositories/AccessRepository.ts b/src/access/domain/repositories/AccessRepository.ts new file mode 100644 index 000000000..69e573492 --- /dev/null +++ b/src/access/domain/repositories/AccessRepository.ts @@ -0,0 +1,16 @@ +export type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +export interface AccessRepository { + submitGuestbookForDatasetDownload: ( + datasetId: number | string, + answers: GuestbookResponseAnswer[] + ) => Promise + submitGuestbookForDatafileDownload: ( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ) => Promise + submitGuestbookForDatafilesDownload: ( + fileIds: Array, + answers: GuestbookResponseAnswer[] + ) => Promise +} diff --git a/src/access/domain/useCases/submitGuestbookForDatafileDownload.ts b/src/access/domain/useCases/submitGuestbookForDatafileDownload.ts new file mode 100644 index 000000000..e2374f54d --- /dev/null +++ b/src/access/domain/useCases/submitGuestbookForDatafileDownload.ts @@ -0,0 +1,9 @@ +import { AccessRepository, GuestbookResponseAnswer } from '../repositories/AccessRepository' + +export function submitGuestbookForDatafileDownload( + accessRepository: AccessRepository, + fileId: number | string, + answers: GuestbookResponseAnswer[] +): Promise { + return accessRepository.submitGuestbookForDatafileDownload(fileId, answers) +} diff --git a/src/access/domain/useCases/submitGuestbookForDatafilesDownload.ts b/src/access/domain/useCases/submitGuestbookForDatafilesDownload.ts new file mode 100644 index 000000000..2d8f1026f --- /dev/null +++ b/src/access/domain/useCases/submitGuestbookForDatafilesDownload.ts @@ -0,0 +1,9 @@ +import { AccessRepository, GuestbookResponseAnswer } from '../repositories/AccessRepository' + +export function submitGuestbookForDatafilesDownload( + accessRepository: AccessRepository, + fileIds: Array, + answers: GuestbookResponseAnswer[] +): Promise { + return accessRepository.submitGuestbookForDatafilesDownload(fileIds, answers) +} diff --git a/src/access/domain/useCases/submitGuestbookForDatasetDownload.ts b/src/access/domain/useCases/submitGuestbookForDatasetDownload.ts new file mode 100644 index 000000000..df46c4e9d --- /dev/null +++ b/src/access/domain/useCases/submitGuestbookForDatasetDownload.ts @@ -0,0 +1,9 @@ +import { AccessRepository, GuestbookResponseAnswer } from '../repositories/AccessRepository' + +export function submitGuestbookForDatasetDownload( + accessRepository: AccessRepository, + datasetId: number | string, + answers: GuestbookResponseAnswer[] +): Promise { + return accessRepository.submitGuestbookForDatasetDownload(datasetId, answers) +} diff --git a/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts b/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts new file mode 100644 index 000000000..7ac5c046d --- /dev/null +++ b/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts @@ -0,0 +1,38 @@ +import { + submitGuestbookForDatasetDownload as submitGuestbookForDatasetDownloadJSDv, + submitGuestbookForDatafileDownload as submitGuestbookForDatafileDownloadJSDv, + submitGuestbookForDatafilesDownload as submitGuestbookForDatafilesDownloadJSDv +} from '@iqss/dataverse-client-javascript' +import { + AccessRepository, + GuestbookResponseAnswer +} from '@/access/domain/repositories/AccessRepository' + +export class AccessJSDataverseRepository implements AccessRepository { + submitGuestbookForDatasetDownload( + datasetId: number | string, + answers: GuestbookResponseAnswer[] + ): Promise { + return submitGuestbookForDatasetDownloadJSDv.execute(datasetId, { + guestbookResponse: { answers } + }) + } + + submitGuestbookForDatafileDownload( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ): Promise { + return submitGuestbookForDatafileDownloadJSDv.execute(fileId, { + guestbookResponse: { answers } + }) + } + + submitGuestbookForDatafilesDownload( + fileIds: Array, + answers: GuestbookResponseAnswer[] + ): Promise { + return submitGuestbookForDatafilesDownloadJSDv.execute(fileIds, { + guestbookResponse: { answers } + }) + } +} diff --git a/src/collection/domain/useCases/createCollection.ts b/src/collection/domain/useCases/createCollection.ts index 67da6fef5..2cbd7986e 100644 --- a/src/collection/domain/useCases/createCollection.ts +++ b/src/collection/domain/useCases/createCollection.ts @@ -1,4 +1,3 @@ -import { WriteError } from '@iqss/dataverse-client-javascript' import { CollectionRepository } from '../repositories/CollectionRepository' import { CollectionDTO } from './DTOs/CollectionDTO' @@ -7,7 +6,5 @@ export function createCollection( collection: CollectionDTO, hostCollection: string ): Promise { - return collectionRepository.create(collection, hostCollection).catch((error: WriteError) => { - throw error - }) + return collectionRepository.create(collection, hostCollection) } diff --git a/src/collection/domain/useCases/deleteCollection.ts b/src/collection/domain/useCases/deleteCollection.ts index cac0450b3..df81e5759 100644 --- a/src/collection/domain/useCases/deleteCollection.ts +++ b/src/collection/domain/useCases/deleteCollection.ts @@ -1,11 +1,8 @@ -import { WriteError } from '@iqss/dataverse-client-javascript' import { CollectionRepository } from '../repositories/CollectionRepository' export function deleteCollection( collectionRepository: CollectionRepository, collectionIdOrAlias: number | string ): Promise { - return collectionRepository.delete(collectionIdOrAlias).catch((error: WriteError | unknown) => { - throw error - }) + return collectionRepository.delete(collectionIdOrAlias) } diff --git a/src/collection/domain/useCases/editCollection.ts b/src/collection/domain/useCases/editCollection.ts index 30385ba87..41f209f80 100644 --- a/src/collection/domain/useCases/editCollection.ts +++ b/src/collection/domain/useCases/editCollection.ts @@ -1,4 +1,3 @@ -import { WriteError } from '@iqss/dataverse-client-javascript' import { CollectionRepository } from '../repositories/CollectionRepository' import { CollectionDTO } from './DTOs/CollectionDTO' @@ -7,7 +6,5 @@ export async function editCollection( updatedCollection: CollectionDTO, collectionId: string ): Promise { - return collectionRepository.edit(collectionId, updatedCollection).catch((error: WriteError) => { - throw error - }) + return collectionRepository.edit(collectionId, updatedCollection) } diff --git a/src/dataset/domain/models/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 19c9935a2..4fb18403a 100644 --- a/src/dataset/domain/models/Dataset.ts +++ b/src/dataset/domain/models/Dataset.ts @@ -438,7 +438,8 @@ export class Dataset { public readonly nextMajorVersion?: string, public readonly nextMinorVersion?: string, public readonly requiresMajorVersionUpdate?: boolean, - public readonly fileStore?: string + public readonly fileStore?: string, + public readonly guestbookId?: number ) {} public checkIsLockedFromPublishing(userPersistentId: string): boolean { @@ -533,7 +534,8 @@ export class Dataset { public readonly nextMajorVersionNumber?: string, public readonly nextMinorVersionNumber?: string, public readonly requiresMajorVersionUpdate?: boolean, - public readonly fileStore?: string + public readonly fileStore?: string, + public readonly guestbookId?: number ) { this.withAlerts() } @@ -605,7 +607,8 @@ export class Dataset { this.nextMajorVersionNumber, this.nextMinorVersionNumber, this.requiresMajorVersionUpdate, - this.fileStore + this.fileStore, + this.guestbookId ) } } diff --git a/src/dataset/domain/useCases/deleteDatasetDraft.ts b/src/dataset/domain/useCases/deleteDatasetDraft.ts index 523e7d3cf..23c52d1b2 100644 --- a/src/dataset/domain/useCases/deleteDatasetDraft.ts +++ b/src/dataset/domain/useCases/deleteDatasetDraft.ts @@ -1,11 +1,8 @@ -import { WriteError } from '@iqss/dataverse-client-javascript' import { DatasetRepository } from '../repositories/DatasetRepository' export function deleteDatasetDraft( datasetRepository: DatasetRepository, datasetId: string | number ): Promise { - return datasetRepository.deleteDatasetDraft(datasetId).catch((error: WriteError | unknown) => { - throw error - }) + return datasetRepository.deleteDatasetDraft(datasetId) } diff --git a/src/dataset/domain/useCases/getDatasetVersionsSummaries.ts b/src/dataset/domain/useCases/getDatasetVersionsSummaries.ts index ae972d66d..24a3c4c4c 100644 --- a/src/dataset/domain/useCases/getDatasetVersionsSummaries.ts +++ b/src/dataset/domain/useCases/getDatasetVersionsSummaries.ts @@ -7,7 +7,5 @@ export function getDatasetVersionsSummaries( datasetId: number | string, paginationInfo?: DatasetVersionPaginationInfo ): Promise { - return datasetRepository.getDatasetVersionsSummaries(datasetId, paginationInfo).catch((error) => { - throw error - }) + return datasetRepository.getDatasetVersionsSummaries(datasetId, paginationInfo) } diff --git a/src/dataset/domain/useCases/updateDatasetLicense.ts b/src/dataset/domain/useCases/updateDatasetLicense.ts index c6db91658..a4d42d051 100644 --- a/src/dataset/domain/useCases/updateDatasetLicense.ts +++ b/src/dataset/domain/useCases/updateDatasetLicense.ts @@ -6,9 +6,5 @@ export function updateDatasetLicense( datasetId: string | number, licenseUpdateRequest: DatasetLicenseUpdateRequest ): Promise { - return datasetRepository - .updateDatasetLicense(datasetId, licenseUpdateRequest) - .catch((error: Error) => { - throw new Error(error.message) - }) + return datasetRepository.updateDatasetLicense(datasetId, licenseUpdateRequest) } diff --git a/src/dataset/domain/useCases/updateTermsOfAccess.ts b/src/dataset/domain/useCases/updateTermsOfAccess.ts index ea44aaf55..fea3398ef 100644 --- a/src/dataset/domain/useCases/updateTermsOfAccess.ts +++ b/src/dataset/domain/useCases/updateTermsOfAccess.ts @@ -6,7 +6,5 @@ export function updateTermsOfAccess( datasetId: string | number, termsOfAccess: TermsOfAccess ): Promise { - return datasetRepository.updateTermsOfAccess(datasetId, termsOfAccess).catch((error: Error) => { - throw new Error(error.message) - }) + return datasetRepository.updateTermsOfAccess(datasetId, termsOfAccess) } diff --git a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts index a147285e5..63c53daf2 100644 --- a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts +++ b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts @@ -100,7 +100,8 @@ export class JSDatasetMapper { latestPublishedVersionMinorNumber ), JSDatasetMapper.toRequiresMajorVersionUpdate(datasetVersionDiff), - fileStore + fileStore, + jsDataset.guestbookId ).build() } diff --git a/src/files/domain/models/File.ts b/src/files/domain/models/File.ts index 934a44635..3d5ff0177 100644 --- a/src/files/domain/models/File.ts +++ b/src/files/domain/models/File.ts @@ -1,4 +1,4 @@ -import { DatasetVersion } from '../../../dataset/domain/models/Dataset' +import { CustomTerms, DatasetLicense, DatasetVersion } from '../../../dataset/domain/models/Dataset' import { FileMetadata } from './FileMetadata' import { FileAccess } from './FileAccess' import { FilePermissions } from './FilePermissions' @@ -9,6 +9,9 @@ import { FileVersionSummarySubset } from './FileVersionSummaryInfo' export interface File { id: number datasetPersistentId: string + guestbookId?: number + datasetLicense?: DatasetLicense + datasetCustomTerms?: CustomTerms name: string access: FileAccess datasetVersion: DatasetVersion diff --git a/src/files/infrastructure/mappers/JSFileMapper.ts b/src/files/infrastructure/mappers/JSFileMapper.ts index 7b1610f17..0389525d4 100644 --- a/src/files/infrastructure/mappers/JSFileMapper.ts +++ b/src/files/infrastructure/mappers/JSFileMapper.ts @@ -62,6 +62,9 @@ export class JSFileMapper { return { id: this.toFileId(jsFile.id), datasetPersistentId: jsDataset.persistentId, + guestbookId: jsDataset.guestbookId, + datasetLicense: jsDataset.license, + datasetCustomTerms: jsDataset.termsOfUse?.customTerms, name: this.toFileName(jsFile.name), access: JSFileAccessMapper.toFileAccess(jsFile.restricted, jsFile.fileAccessRequest || false), datasetVersion: datasetVersion, diff --git a/src/guestbooks/domain/models/Guestbook.ts b/src/guestbooks/domain/models/Guestbook.ts new file mode 100644 index 000000000..2a2f3c5b7 --- /dev/null +++ b/src/guestbooks/domain/models/Guestbook.ts @@ -0,0 +1,28 @@ +export type GuestbookQuestionType = 'text' | 'textarea' | 'options' + +export interface GuestbookOption { + value: string + displayOrder: number +} + +export interface GuestbookCustomQuestion { + question: string + required: boolean + displayOrder: number + type: GuestbookQuestionType + hidden: boolean + optionValues?: GuestbookOption[] +} + +export interface Guestbook { + id: number + name: string + enabled: boolean + emailRequired: boolean + nameRequired: boolean + institutionRequired: boolean + positionRequired: boolean + customQuestions: GuestbookCustomQuestion[] + createTime: string + dataverseId: number +} diff --git a/src/guestbooks/domain/repositories/GuestbookRepository.ts b/src/guestbooks/domain/repositories/GuestbookRepository.ts new file mode 100644 index 000000000..ab695ec31 --- /dev/null +++ b/src/guestbooks/domain/repositories/GuestbookRepository.ts @@ -0,0 +1,5 @@ +import { Guestbook } from '../models/Guestbook' + +export interface GuestbookRepository { + getGuestbook: (guestbookId: number) => Promise +} diff --git a/src/guestbooks/domain/useCases/getGuestbook.ts b/src/guestbooks/domain/useCases/getGuestbook.ts new file mode 100644 index 000000000..40e32d615 --- /dev/null +++ b/src/guestbooks/domain/useCases/getGuestbook.ts @@ -0,0 +1,9 @@ +import { Guestbook } from '../models/Guestbook' +import { GuestbookRepository } from '../repositories/GuestbookRepository' + +export async function getGuestbook( + guestbookRepository: GuestbookRepository, + guestbookId: number +): Promise { + return guestbookRepository.getGuestbook(guestbookId) +} diff --git a/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts new file mode 100644 index 000000000..0ac479592 --- /dev/null +++ b/src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts @@ -0,0 +1,9 @@ +import { getGuestbook } from '@iqss/dataverse-client-javascript' +import { GuestbookRepository } from '../../domain/repositories/GuestbookRepository' +import { Guestbook } from '../../domain/models/Guestbook' + +export class GuestbookJSDataverseRepository implements GuestbookRepository { + getGuestbook(guestbookId: number): Promise { + return getGuestbook.execute(guestbookId).then((guestbook) => guestbook as Guestbook) + } +} diff --git a/src/info/domain/useCases/getTermsOfUse.ts b/src/info/domain/useCases/getTermsOfUse.ts index 53eaa2324..7cc30f213 100644 --- a/src/info/domain/useCases/getTermsOfUse.ts +++ b/src/info/domain/useCases/getTermsOfUse.ts @@ -4,7 +4,5 @@ import { DataverseInfoRepository } from '../repositories/DataverseInfoRepository export function getTermsOfUse( dataverseInfoRepository: DataverseInfoRepository ): Promise { - return dataverseInfoRepository.getTermsOfUse().catch((error) => { - throw error - }) + return dataverseInfoRepository.getTermsOfUse() } diff --git a/src/sections/dataset/dataset-action-buttons/DatasetActionButtons.tsx b/src/sections/dataset/dataset-action-buttons/DatasetActionButtons.tsx index 49341189b..4f246d34c 100644 --- a/src/sections/dataset/dataset-action-buttons/DatasetActionButtons.tsx +++ b/src/sections/dataset/dataset-action-buttons/DatasetActionButtons.tsx @@ -41,6 +41,9 @@ export function DatasetActionButtons({ downloadUrls={dataset.downloadUrls} fileStore={dataset.fileStore} persistentId={dataset.persistentId} + guestbookId={dataset.guestbookId} + license={dataset.license} + customTerms={dataset.termsOfUse.customTerms} /> value).reduce((acc, curr) => acc + curr, 0) === 0 @@ -50,27 +62,41 @@ export function AccessDatasetMenu({ return <>> } - // TODO: remove this when access datafile supports bearer tokens - if (version.publishingStatus === DatasetPublishingStatus.DRAFT) { - return <>> + const handleDownloadWithGuestbook = (event: React.MouseEvent) => { + event.preventDefault() + setShowDownloadWithGuestbookModal(true) } return ( - - - {t('datasetActionButtons.accessDataset.downloadOptions.header')} - - - - + <> + + + {t('datasetActionButtons.accessDataset.downloadOptions.header')} + + + + + {hasGuestbook && ( + setShowDownloadWithGuestbookModal(false)} + guestbookId={guestbookId} + datasetPersistentId={persistentId} + datasetLicense={license} + datasetCustomTerms={customTerms} + /> + )} + > ) } @@ -78,12 +104,16 @@ interface DatasetDownloadOptionsProps { hasOneTabularFileAtLeast: boolean fileDownloadSizes: FileDownloadSize[] downloadUrls: DatasetDownloadUrls + hasGuestbook: boolean + onDownloadWithGuestbook: (event: React.MouseEvent) => void } const DatasetDownloadOptions = ({ hasOneTabularFileAtLeast, fileDownloadSizes, - downloadUrls + downloadUrls, + hasGuestbook, + onDownloadWithGuestbook }: DatasetDownloadOptionsProps) => { const { t } = useTranslation('dataset') function getFormattedFileSize(mode: FileDownloadMode): string { @@ -93,17 +123,23 @@ const DatasetDownloadOptions = ({ return hasOneTabularFileAtLeast ? ( <> - + {t('datasetActionButtons.accessDataset.downloadOptions.originalZip')} ( {getFormattedFileSize(FileDownloadMode.ORIGINAL)}) - + {t('datasetActionButtons.accessDataset.downloadOptions.archivalZip')} ( {getFormattedFileSize(FileDownloadMode.ARCHIVAL)}) > ) : ( - + {t('datasetActionButtons.accessDataset.downloadOptions.zip')} ( {getFormattedFileSize(FileDownloadMode.ORIGINAL)}) diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx index 19b3ea066..4352a3d5f 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx @@ -11,6 +11,7 @@ import { useMultipleFileDownload } from '../../../../../file/multiple-file-downl import { FilePreview } from '../../../../../../files/domain/models/FilePreview' import { useMediaQuery } from '../../../../../../shared/hooks/useMediaQuery' import { DatasetPublishingStatus } from '@/dataset/domain/models/Dataset' +import { DownloadWithGuestbookModal } from '../file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal' interface DownloadFilesButtonProps { files: FilePreview[] @@ -24,23 +25,35 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto const { t } = useTranslation('files') const { dataset } = useDataset() const [showNoFilesSelectedModal, setShowNoFilesSelectedModal] = useState(false) + const [showDownloadWithGuestbookModal, setShowDownloadWithGuestbookModal] = useState(false) const { getMultipleFileDownloadUrl } = useMultipleFileDownload() const isBelow768px = useMediaQuery('(max-width: 768px)') const fileSelectionCount = Object.keys(fileSelection).length - const onClick = (event: MouseEvent) => { + const allFilesSelected = Object.values(fileSelection).some((file) => file === undefined) + const selectedFileIds = getFileIdsFromSelection(fileSelection) + const allSelectedFileIds = files.map((file) => file.id) + const fileIdsForGuestbookSubmission = allFilesSelected ? allSelectedFileIds : selectedFileIds + const hasGuestbook = dataset?.guestbookId !== undefined + const onClick = (event: MouseEvent) => { if (fileSelectionCount === SELECTED_FILES_EMPTY) { event.preventDefault() setShowNoFilesSelectedModal(true) + return + } + + if (hasGuestbook) { + event.preventDefault() + setShowDownloadWithGuestbookModal(true) } } + const getDownloadUrl = (downloadMode: FileDownloadMode): string => { - const allFilesSelected = Object.values(fileSelection).some((file) => file === undefined) if (allFilesSelected) { return dataset ? dataset.downloadUrls[downloadMode] : '' } - return getMultipleFileDownloadUrl(getFileIdsFromSelection(fileSelection), downloadMode) + return getMultipleFileDownloadUrl(selectedFileIds, downloadMode) } if ( @@ -74,10 +87,14 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto ariaLabel={t('actions.downloadFiles.title')} variant="secondary" withSpacing> - + {t('actions.downloadFiles.options.original')} - + {t('actions.downloadFiles.options.archival')} @@ -85,6 +102,38 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto show={showNoFilesSelectedModal} handleClose={() => setShowNoFilesSelectedModal(false)} /> + setShowDownloadWithGuestbookModal(false)} + /> + > + ) + } + + if (hasGuestbook) { + return ( + <> + } + aria-label={t('actions.downloadFiles.title')} + withSpacing + onClick={onClick}> + {dropdownButtonTitle} + + setShowNoFilesSelectedModal(false)} + /> + setShowDownloadWithGuestbookModal(false)} + /> > ) } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.module.scss b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.module.scss new file mode 100644 index 000000000..74facdb46 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.module.scss @@ -0,0 +1,40 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.form-row { + margin-bottom: 1rem; +} + +.row-label { + font-weight: 600; +} + +.community-norms-text { + color: $dv-subtext-color; + margin-bottom: 0.75rem; +} + +.license-icon { + margin-right: 0.5rem; +} + +.required { + color: $dv-danger-color; + margin-left: 0.2rem; +} + +.question-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.question-item { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.question-label { + font-weight: 600; + margin: 0; +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.tsx new file mode 100644 index 000000000..f06b4c391 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Button, Modal, Spinner } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' +import { useDataset } from '@/sections/dataset/DatasetContext' +import { useGetGuestbookById } from '@/sections/dataset/dataset-guestbook/useGetGuestbookById' +import { + GuestbookAppliedForm, + getGuestbookCustomQuestionFieldName, + isGuestbookAppliedFormEmailValid +} from './GuestbookAppliedForm' +import { useSession } from '@/sections/session/SessionContext' +import { Guestbook, GuestbookCustomQuestion } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { useGuestbookAppliedSubmission } from './useGuestbookAppliedSubmission' +import { GuestbookJSDataverseRepository } from '@/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository' +import { AccessJSDataverseRepository } from '@/access/infrastructure/repositories/AccessJSDataverseRepository' +import { CustomTerms as CustomTermsModel, DatasetLicense } from '@/dataset/domain/models/Dataset' + +interface DownloadWithGuestbookModalProps { + fileId?: number | string + fileIds?: Array + guestbookId?: number + datasetPersistentId?: string + datasetLicense?: DatasetLicense + datasetCustomTerms?: CustomTermsModel + show: boolean + handleClose: () => void + guestbookRepository?: GuestbookRepository + accessRepository?: AccessRepository +} + +type GuestbookFormValues = Record +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +export function DownloadWithGuestbookModal({ + fileId, + fileIds, + guestbookId, + datasetPersistentId, + datasetLicense, + datasetCustomTerms, + show, + handleClose, + guestbookRepository, + accessRepository +}: DownloadWithGuestbookModalProps) { + const { t: tFiles } = useTranslation('files') + const { t: tDataset } = useTranslation('dataset') + const { dataset } = useDataset() + const { user } = useSession() + const [formValues, setFormValues] = useState({}) + const resolvedGuestbookRepository = useMemo( + () => guestbookRepository ?? new GuestbookJSDataverseRepository(), + [guestbookRepository] + ) + const resolvedAccessRepository = useMemo( + () => accessRepository ?? new AccessJSDataverseRepository(), + [accessRepository] + ) + const { guestbook, isLoadingGuestbook, errorGetGuestbook } = useGetGuestbookById({ + guestbookRepository: resolvedGuestbookRepository, + guestbookId + }) + + const resolvedDatasetPersistentId = datasetPersistentId ?? dataset?.persistentId + const resolvedDatasetLicense = datasetLicense ?? dataset?.license + const resolvedDatasetCustomTerms = datasetCustomTerms ?? dataset?.termsOfUse.customTerms + + const accountFieldKeys = useMemo(() => ['name', 'email', 'institution', 'position'], []) + + const prefilledAccountFieldValues = useMemo(() => { + if (!user) { + return {} + } + + return accountFieldKeys.reduce((prefilledValues, fieldName) => { + if (fieldName === 'name') { + prefilledValues[fieldName] = + `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() || user.displayName + } + if (fieldName === 'email') { + prefilledValues[fieldName] = user.email + } + if (fieldName === 'institution') { + prefilledValues[fieldName] = user.affiliation ?? '' + } + if (fieldName === 'position') { + prefilledValues[fieldName] = user.position ?? '' + } + + return prefilledValues + }, {}) + }, [accountFieldKeys, user]) + + const isAccountFieldRequired = (fieldName: string): boolean => { + if (!guestbook) { + return false + } + + switch (fieldName) { + case 'name': + return guestbook.nameRequired + case 'email': + return guestbook.emailRequired + case 'institution': + return guestbook.institutionRequired + case 'position': + return guestbook.positionRequired + default: + return false + } + } + + const accountFieldErrors = accountFieldKeys.reduce>( + (errors, fieldName) => { + const value = (formValues[fieldName] ?? '').trim() + + if (isAccountFieldRequired(fieldName) && value.length === 0) { + errors[fieldName] = tFiles('actions.optionsMenu.guestbookAppliedModal.validation.required') + return errors + } + + if (fieldName === 'email' && value.length > 0 && !isGuestbookAppliedFormEmailValid(value)) { + errors[fieldName] = tFiles( + 'actions.optionsMenu.guestbookAppliedModal.validation.invalidEmail' + ) + return errors + } + + errors[fieldName] = null + return errors + }, + {} + ) + + const hasAccountFieldErrors = Object.values(accountFieldErrors).some((error) => error !== null) + const customQuestions = useMemo( + () => (guestbook?.customQuestions ?? []).filter((question) => !question.hidden), + [guestbook?.customQuestions] + ) + + const resolveAnswerId = ( + fieldName: string, + question?: GuestbookCustomQuestion, + guestbookData?: Guestbook + ): string | number => { + const guestbookWithAnswerIds = guestbookData as Guestbook & { + email?: string + institution?: string + position?: string + } + const questionWithId = question as GuestbookCustomQuestion & { id?: number | string } + + if (questionWithId?.id !== undefined) { + return questionWithId.id + } + + if (fieldName === 'email') { + return guestbookWithAnswerIds.email ?? fieldName + } + if (fieldName === 'institution') { + return guestbookWithAnswerIds.institution ?? fieldName + } + if (fieldName === 'position') { + return guestbookWithAnswerIds.position ?? fieldName + } + + return fieldName + } + + const buildGuestbookAnswers = () => { + const accountAnswers = accountFieldKeys.reduce( + (answers, fieldName) => { + const value = (formValues[fieldName] ?? '').trim() + if (value.length === 0) { + return answers + } + + answers.push({ + id: resolveAnswerId(fieldName, undefined, guestbook), + value + }) + + return answers + }, + [] + ) + + const customQuestionAnswers = customQuestions.reduce( + (answers, question, index) => { + const fieldName = getGuestbookCustomQuestionFieldName(question, index) + const value = (formValues[fieldName] ?? '').trim() + if (value.length === 0) { + return answers + } + + answers.push({ + id: resolveAnswerId(fieldName, question, guestbook), + value + }) + + return answers + }, + [] + ) + + return [...accountAnswers, ...customQuestionAnswers] + } + const buildSignedUrl = (signedUrl: string): string => { + try { + const signedUrlObject = new URL(signedUrl, window.location.origin) + return new URL( + `${signedUrlObject.pathname}${signedUrlObject.search}${signedUrlObject.hash}`, + window.location.origin + ).toString() + } catch { + return signedUrl + } + } + + const triggerDirectDownload = (signedUrl: string): Promise => { + const downloadLink = document.createElement('a') + downloadLink.href = buildSignedUrl(signedUrl) + downloadLink.style.display = 'none' + downloadLink.rel = 'noreferrer' + + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) + + return Promise.resolve() + } + + const { + hasAttemptedAccept, + errorDownloadSignedUrlFile, + errorSubmitGuestbook, + isSubmittingGuestbook, + handleModalClose, + handleSubmit, + resetSubmissionState + } = useGuestbookAppliedSubmission({ + datasetPersistentId: resolvedDatasetPersistentId, + fileId, + fileIds, + handleClose, + accessRepository: resolvedAccessRepository, + triggerDirectDownload + }) + + useEffect(() => { + if (show) { + setFormValues(prefilledAccountFieldValues) + resetSubmissionState() + } + }, [show, prefilledAccountFieldValues, resetSubmissionState]) + + const updateFieldValue = (fieldName: string, value: string) => { + setFormValues((current) => ({ + ...current, + [fieldName]: value + })) + } + + return ( + + + {tDataset('termsTab.licenseTitle')} + + + {isLoadingGuestbook ? ( + + ) : ( + <> + {errorGetGuestbook && {errorGetGuestbook}} + {errorSubmitGuestbook && {errorSubmitGuestbook}} + {errorDownloadSignedUrlFile && ( + {errorDownloadSignedUrlFile} + )} + + > + )} + + + + void handleSubmit({ + hasAccountFieldErrors, + guestbook, + answers: buildGuestbookAnswers() + }) + } + disabled={ + isLoadingGuestbook || isSubmittingGuestbook || !!errorGetGuestbook || !guestbook + } + aria-label={tFiles('requestAccess.confirmation')}> + {isSubmittingGuestbook ? : null}{' '} + {tFiles('requestAccess.confirmation')} + + + {tFiles('requestAccess.cancel')} + + + + ) +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedForm.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedForm.tsx new file mode 100644 index 000000000..29f6ed6cf --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedForm.tsx @@ -0,0 +1,223 @@ +import { useMemo } from 'react' +import { Col, DropdownButton, DropdownButtonItem, Form, Row } from '@iqss/dataverse-design-system' +import { Trans, useTranslation } from 'react-i18next' +import { Guestbook, GuestbookCustomQuestion } from '@/guestbooks/domain/models/Guestbook' +import { CustomTerms as CustomTermsModel, DatasetLicense } from '@/dataset/domain/models/Dataset' +import { Validator as validator } from '@/shared/helpers/Validator' +import { CustomTerms } from '@/sections/dataset/dataset-terms/CustomTerms' +import { QueryParamKey, Route } from '@/sections/Route.enum' +import styles from './DownloadWithGuestbookModal.module.scss' + +interface GuestbookAppliedFormProps { + license?: DatasetLicense + customTerms?: CustomTermsModel + datasetPersistentId?: string + guestbook?: Guestbook + formValues: Record + hasAttemptedAccept: boolean + accountFieldErrors: Record + accountFieldKeys: string[] + shouldLockIdentityFields: boolean + isAccountFieldRequired: (fieldName: string) => boolean + onFieldChange: (fieldName: string, value: string) => void +} + +export const getGuestbookCustomQuestionFieldName = ( + question: GuestbookCustomQuestion, + index: number +): string => `custom-question-${question.displayOrder}-${index}` + +export const isGuestbookAppliedFormEmailValid = (email: string): boolean => + validator.isValidEmail(email) + +export function GuestbookAppliedForm({ + license, + customTerms, + datasetPersistentId, + guestbook, + formValues, + hasAttemptedAccept, + accountFieldErrors, + accountFieldKeys, + shouldLockIdentityFields, + isAccountFieldRequired, + onFieldChange +}: GuestbookAppliedFormProps) { + const { t: tFiles } = useTranslation('files') + const { t: tDataset } = useTranslation('dataset') + const { t: tGuestbooks } = useTranslation('guestbooks') + + const customQuestions = useMemo( + () => (guestbook?.customQuestions ?? []).filter((question) => !question.hidden), + [guestbook?.customQuestions] + ) + const customTermsHref = datasetPersistentId + ? `/spa${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${encodeURIComponent( + datasetPersistentId + )}&${QueryParamKey.TAB}=terms&termsTab=guestbook` + : undefined + + const renderCustomQuestionField = (question: GuestbookCustomQuestion, index: number) => { + const fieldName = getGuestbookCustomQuestionFieldName(question, index) + const value = formValues[fieldName] ?? '' + const isRequired = question.required + + if (question.type === 'textarea') { + return ( + + onFieldChange(fieldName, (event.target as HTMLTextAreaElement).value) + } + aria-required={isRequired} + rows={3} + /> + ) + } + + if (question.type === 'options') { + return ( + onFieldChange(fieldName, eventKey ?? '')} + variant="secondary" + aria-required={isRequired}> + Select... + {(question.optionValues ?? []).map((option) => ( + + {option.value} + + ))} + + ) + } + + return ( + onFieldChange(fieldName, (event.target as HTMLInputElement).value)} + aria-required={isRequired} + /> + ) + } + + return ( + + + + + {tDataset('license.title')} + + + + + + ) + }} + /> + + {license?.iconUri && ( + + )} + {license && ( + + {license.name} + + )} + {!license && customTerms && ( + + {customTermsHref ? ( + {tDataset('customTerms.title')} + ) : ( + tDataset('customTerms.title') + )}{' '} + - {tDataset('customTerms.description')} + + )} + + + + + {accountFieldKeys.map((accountFieldKey) => { + const fieldLabel = tGuestbooks(`create.fields.dataCollected.options.${accountFieldKey}`) + const isRequired = isAccountFieldRequired(accountFieldKey) + const isIdentityField = accountFieldKey === 'name' || accountFieldKey === 'email' + const shouldDisableInput = shouldLockIdentityFields && isIdentityField + const hasError = + (hasAttemptedAccept || accountFieldKey === 'email') && + accountFieldErrors[accountFieldKey] !== null + + return ( + + + + {fieldLabel} + {isRequired && *} + + + + + onFieldChange(accountFieldKey, (event.target as HTMLInputElement).value) + } + isInvalid={hasError} + disabled={shouldDisableInput} + aria-required={isRequired} + /> + {hasError && ( + + {accountFieldErrors[accountFieldKey]} + + )} + + + ) + })} + + {customQuestions.length > 0 && ( + + + + {tFiles('actions.optionsMenu.guestbookAppliedModal.additionalQuestions')} + + + + {customQuestions.map((question, index) => ( + + + {question.question} + {question.required && *} + + {renderCustomQuestionField(question, index)} + + ))} + + + )} + + ) +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.ts b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.ts new file mode 100644 index 000000000..7d0060084 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.ts @@ -0,0 +1,123 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { submitGuestbookForDatasetDownload } from '@/access/domain/useCases/submitGuestbookForDatasetDownload' +import { submitGuestbookForDatafileDownload } from '@/access/domain/useCases/submitGuestbookForDatafileDownload' +import { submitGuestbookForDatafilesDownload } from '@/access/domain/useCases/submitGuestbookForDatafilesDownload' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +interface UseGuestbookAppliedSubmissionProps { + datasetPersistentId?: string + fileId?: number | string + fileIds?: Array + handleClose: () => void + accessRepository: AccessRepository + triggerDirectDownload: (signedUrl: string) => Promise +} + +interface HandleSubmitProps { + hasAccountFieldErrors: boolean + guestbook?: Guestbook + answers: GuestbookResponseAnswer[] +} + +export const useGuestbookAppliedSubmission = ({ + datasetPersistentId, + fileId, + fileIds, + handleClose, + accessRepository, + triggerDirectDownload +}: UseGuestbookAppliedSubmissionProps) => { + const { t: tFiles } = useTranslation('files') + const [hasAttemptedAccept, setHasAttemptedAccept] = useState(false) + const [isSubmittingGuestbook, setIsSubmittingGuestbook] = useState(false) + const [errorSubmitGuestbook, setErrorSubmitGuestbook] = useState(null) + const [errorDownloadSignedUrlFile, setErrorDownloadSignedUrlFile] = useState(null) + + const resetSubmissionState = useCallback(() => { + setHasAttemptedAccept(false) + setIsSubmittingGuestbook(false) + setErrorSubmitGuestbook(null) + setErrorDownloadSignedUrlFile(null) + }, []) + + const handleModalClose = useCallback(() => { + resetSubmissionState() + handleClose() + }, [handleClose, resetSubmissionState]) + + const handleSubmit = useCallback( + async ({ hasAccountFieldErrors, guestbook, answers }: HandleSubmitProps) => { + setHasAttemptedAccept(true) + setErrorDownloadSignedUrlFile(null) + + if (hasAccountFieldErrors || !guestbook) { + return + } + + let signedUrl: string | undefined + setIsSubmittingGuestbook(true) + setErrorSubmitGuestbook(null) + + try { + if (fileId !== undefined) { + signedUrl = await submitGuestbookForDatafileDownload(accessRepository, fileId, answers) + } else if (fileIds && fileIds.length > 0) { + signedUrl = await submitGuestbookForDatafilesDownload(accessRepository, fileIds, answers) + } else if (datasetPersistentId !== undefined) { + signedUrl = await submitGuestbookForDatasetDownload( + accessRepository, + datasetPersistentId, + answers + ) + } else { + return + } + } catch (err) { + if (err instanceof WriteError) { + const errorHandler = new JSDataverseWriteErrorHandler(err) + const formattedError = + errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() + setErrorSubmitGuestbook(formattedError) + } else { + setErrorSubmitGuestbook(tFiles('actions.optionsMenu.guestbookAppliedModal.submitError')) + } + } finally { + setIsSubmittingGuestbook(false) + } + + if (signedUrl) { + handleModalClose() + void triggerDirectDownload(signedUrl).catch((error) => { + const fallbackMessage = tFiles('actions.optionsMenu.guestbookAppliedModal.downloadError') + const errorMessage = error instanceof Error ? error.message : fallbackMessage + setErrorDownloadSignedUrlFile(errorMessage) + }) + } + }, + [ + datasetPersistentId, + fileId, + fileIds, + handleModalClose, + accessRepository, + tFiles, + triggerDirectDownload + ] + ) + + return { + hasAttemptedAccept, + errorDownloadSignedUrlFile, + errorSubmitGuestbook, + isSubmittingGuestbook, + handleModalClose, + handleSubmit, + resetSubmissionState + } +} diff --git a/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx new file mode 100644 index 000000000..7ab0284bc --- /dev/null +++ b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx @@ -0,0 +1,90 @@ +import { useMemo, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Button, Col, QuestionMarkTooltip, Row, Spinner } from '@iqss/dataverse-design-system' +import { useGetGuestbookById } from './useGetGuestbookById' +import { GuestbookJSDataverseRepository } from '@/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' +import { useDataset } from '@/sections/dataset/DatasetContext' +import styles from '@/sections/dataset/dataset-terms/DatasetTerms.module.scss' + +interface DatasetGuestbookProps { + guestbookRepository?: GuestbookRepository +} + +export const DatasetGuestbook = ({ guestbookRepository }: DatasetGuestbookProps) => { + const { t } = useTranslation('dataset') + const { dataset } = useDataset() + const [showPreview, setShowPreview] = useState(false) + const repository = useMemo( + () => guestbookRepository ?? new GuestbookJSDataverseRepository(), + [guestbookRepository] + ) + const datasetHasGuestbook = dataset?.guestbookId !== undefined + const { guestbook, isLoadingGuestbook } = useGetGuestbookById({ + guestbookRepository: repository, + guestbookId: dataset?.guestbookId as number + }) + const hasGuestbook = guestbook !== undefined + + return ( + <> + + + {t('termsTab.guestbookTitle')} + + + + {!datasetHasGuestbook ? ( + + + ) + }} + /> + + ) : ( + <> + + {t('termsTab.guestbookDescription')} + + + {isLoadingGuestbook ? ( + + ) : ( + <> + {guestbook?.name ?? '-'} + {hasGuestbook && ( + setShowPreview(true)}> + {t('termsTab.guestbookPreviewButton')} + + )} + > + )} + + > + )} + + + {guestbook && ( + setShowPreview(false)} + guestbook={guestbook} + /> + )} + > + ) +} diff --git a/src/sections/dataset/dataset-guestbook/useGetGuestbookById.ts b/src/sections/dataset/dataset-guestbook/useGetGuestbookById.ts new file mode 100644 index 000000000..468c283be --- /dev/null +++ b/src/sections/dataset/dataset-guestbook/useGetGuestbookById.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { useTranslation } from 'react-i18next' +import { Guestbook } from '../../../guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '../../../guestbooks/domain/repositories/GuestbookRepository' +import { getGuestbook } from '../../../guestbooks/domain/useCases/getGuestbook' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +interface useGetGuestbookByIdProps { + guestbookRepository: GuestbookRepository + guestbookId?: number +} + +export const useGetGuestbookById = ({ + guestbookRepository, + guestbookId +}: useGetGuestbookByIdProps) => { + const { t } = useTranslation('guestbooks') + const [guestbook, setGuestbook] = useState(undefined) + const [isLoadingGuestbook, setIsLoadingGuestbook] = useState(guestbookId !== undefined) + const [errorGetGuestbook, setErrorGetGuestbook] = useState(null) + + const fetchGuestbook = useCallback(async () => { + if (guestbookId === undefined) { + setGuestbook(undefined) + setIsLoadingGuestbook(false) + setErrorGetGuestbook(null) + return + } + + setIsLoadingGuestbook(true) + setErrorGetGuestbook(null) + + try { + const guestbookResponse = await getGuestbook(guestbookRepository, guestbookId) + setGuestbook(guestbookResponse) + } catch (err) { + setGuestbook(undefined) + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + setErrorGetGuestbook(formattedError) + } else { + setErrorGetGuestbook(t('errors.getGuestbook')) + } + } finally { + setIsLoadingGuestbook(false) + } + }, [guestbookId, guestbookRepository, t]) + + useEffect(() => { + void fetchGuestbook() + }, [fetchGuestbook]) + + return { + guestbook, + isLoadingGuestbook, + errorGetGuestbook, + fetchGuestbook + } +} diff --git a/src/sections/dataset/dataset-terms/DatasetTerms.module.scss b/src/sections/dataset/dataset-terms/DatasetTerms.module.scss index 98f50427c..b4e88a6f8 100644 --- a/src/sections/dataset/dataset-terms/DatasetTerms.module.scss +++ b/src/sections/dataset/dataset-terms/DatasetTerms.module.scss @@ -9,3 +9,12 @@ margin: 10px 0; color: $dv-subtext-color; } + +.guestbook-selection { + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 0.75rem; +} diff --git a/src/sections/dataset/dataset-terms/DatasetTerms.tsx b/src/sections/dataset/dataset-terms/DatasetTerms.tsx index b181ff538..01b202794 100644 --- a/src/sections/dataset/dataset-terms/DatasetTerms.tsx +++ b/src/sections/dataset/dataset-terms/DatasetTerms.tsx @@ -14,6 +14,9 @@ import { SpinnerSymbol } from '@/sections/dataset/dataset-files/files-table/spin import { CustomTerms } from '@/sections/dataset/dataset-terms/CustomTerms' import { TermsOfAccess } from '@/sections/dataset/dataset-terms/TermsOfAccess' import { License } from '@/sections/dataset/dataset-terms/License' +import { DatasetGuestbook } from '@/sections/dataset/dataset-guestbook/DatasetGuestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { useSearchParams } from 'react-router-dom' interface DatasetTermsProps { license: DatasetLicense | undefined @@ -22,6 +25,7 @@ interface DatasetTermsProps { datasetPersistentId: string datasetVersion: DatasetVersion canUpdateDataset?: boolean + guestbookRepository?: GuestbookRepository } export function DatasetTerms({ @@ -30,9 +34,11 @@ export function DatasetTerms({ filesRepository, datasetPersistentId, datasetVersion, - canUpdateDataset + canUpdateDataset, + guestbookRepository }: DatasetTermsProps) { const { t } = useTranslation('dataset') + const [searchParams] = useSearchParams() const { filesCountInfo, isLoading } = useGetFilesCountInfo({ filesRepository, datasetPersistentId, @@ -46,15 +52,24 @@ export function DatasetTerms({ (value) => value === undefined || typeof value === 'boolean' ) const displayTermsOfAccess = !termsOfAccessIsEmpty || restrictedFilesCount > 0 + const shouldOpenGuestbookSection = searchParams.get('termsTab') === 'guestbook' if (isLoading) { return } + const defaultActiveKeys = ['0'] + if (displayTermsOfAccess) { + defaultActiveKeys.push('1') + } + if (shouldOpenGuestbookSection) { + defaultActiveKeys.push('2') + } + return ( <> - + {t('termsTab.licenseTitle')} @@ -74,6 +89,14 @@ export function DatasetTerms({ )} + + + {t('termsTab.guestbookTitle')} + + + + + > ) diff --git a/src/sections/edit-dataset-terms/EditDatasetTerms.tsx b/src/sections/edit-dataset-terms/EditDatasetTerms.tsx index edbfe7f02..4d079b729 100644 --- a/src/sections/edit-dataset-terms/EditDatasetTerms.tsx +++ b/src/sections/edit-dataset-terms/EditDatasetTerms.tsx @@ -7,7 +7,7 @@ import { EditLicenseAndTerms } from './edit-license-and-terms/EditLicenseAndTerm import { EditTermsOfAccess } from './edit-terms-of-access/EditTermsOfAccess' import { LicenseRepository } from '../../licenses/domain/repositories/LicenseRepository' import { DatasetRepository } from '../../dataset/domain/repositories/DatasetRepository' -import { EditGuestBook } from './edit-guest-book/EditGuestBook' +import { EditGuestbook } from './edit-guestbook/EditGuestbook' import { useDataset } from '../dataset/DatasetContext' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { NotFoundPage } from '../not-found-page/NotFoundPage' @@ -40,7 +40,7 @@ export const EditDatasetTerms = ({ const [licenseFormIsDirty, setLicenseFormIsDirty] = useState(false) const [termsOfAccessFormIsDirty, setTermsOfAccessFormIsDirty] = useState(false) - const [guestBookFormIsDirty, setGuestBookFormIsDirty] = useState(false) + const [guestbookFormIsDirty, _setGuestbookFormIsDirty] = useState(false) useEffect(() => { setIsLoading(isLoading) @@ -60,8 +60,8 @@ export const EditDatasetTerms = ({ return licenseFormIsDirty case tabsKeys.restrictedFilesTerms: return termsOfAccessFormIsDirty - case tabsKeys.guestBook: - return guestBookFormIsDirty + case tabsKeys.guestbook: + return guestbookFormIsDirty default: return false } @@ -131,11 +131,11 @@ export const EditDatasetTerms = ({ - - {t('editTerms.tabs.guestBook')} + + {t('editTerms.tabs.guestbook')} - + @@ -163,12 +163,9 @@ export const EditDatasetTerms = ({ - + - + diff --git a/src/sections/edit-dataset-terms/EditDatasetTermsHelper.ts b/src/sections/edit-dataset-terms/EditDatasetTermsHelper.ts index bb128ea47..8b80e2be2 100644 --- a/src/sections/edit-dataset-terms/EditDatasetTermsHelper.ts +++ b/src/sections/edit-dataset-terms/EditDatasetTermsHelper.ts @@ -2,7 +2,7 @@ export class EditDatasetTermsHelper { static EDIT_DATASET_TERMS_TABS_KEYS = { datasetTerms: 'datasetTerms', restrictedFilesTerms: 'restrictedFilesTerms', - guestBook: 'guestBook' + guestbook: 'guestbook' } as const static EDIT_DATASET_TERMS_TAB_QUERY_KEY = 'tab' diff --git a/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.module.scss b/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.module.scss deleted file mode 100644 index 575fca333..000000000 --- a/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.module.scss +++ /dev/null @@ -1,30 +0,0 @@ -.guestbook-tab { - max-width: 900px; - - .guestbook-option { - display: flex; - gap: 1rem; - align-items: center; - margin: 0.5rem; - margin-left: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--bs-light); - border-radius: 4px; - } - - .form-actions { - display: flex; - gap: 1rem; - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid var(--bs-border-color); - } - - .section-title { - margin-bottom: 0; - margin-left: 0.5rem; - color: var(--bs-dark); - font-weight: 600; - font-size: 1rem; - } -} diff --git a/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.tsx b/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.tsx deleted file mode 100644 index 4e44ff110..000000000 --- a/src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useState } from 'react' -import { Button, Col, Form, Row } from '@iqss/dataverse-design-system' -import { useTranslation } from 'react-i18next' -import { NotImplementedModal } from '../../not-implemented/NotImplementedModal' -import styles from './EditGuestBook.module.scss' - -interface EditGuestBookProps { - onPreview?: () => void -} - -export function EditGuestBook({ onPreview }: EditGuestBookProps) { - const { t } = useTranslation('dataset') - const { t: tShared } = useTranslation('shared') - const [showNotImplementedModal, setShowNotImplementedModal] = useState(false) - - const handlePreview = () => { - if (onPreview) { - onPreview() - } else { - setShowNotImplementedModal(true) - } - } - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault() - setShowNotImplementedModal(true) - } - - const handleCloseModal = () => { - setShowNotImplementedModal(false) - } - - return ( - - - - - {t('editTerms.guestBook.title')} - - - {t('editTerms.guestBook.description')} - - - - - {t('editTerms.guestBook.previewButton')} - - - - - - - - {tShared('saveChanges')} - - {tShared('cancel')} - - - - - - - ) -} diff --git a/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.module.scss b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.module.scss new file mode 100644 index 000000000..16366359d --- /dev/null +++ b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.module.scss @@ -0,0 +1,39 @@ +.edit-guest-book { + .guestbook-description { + margin-bottom: 1rem; + } + + .guestbook-list { + margin-top: 0.75rem; + border: 1px solid var(--bs-border-color); + } + + .guestbook-loading { + padding: 0.75rem; + } + + .guestbook-option { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.75rem; + border-top: 1px solid var(--bs-border-color); + } + + .guestbook-option:first-child { + border-top: 0; + } + + .guestbook-option-selected { + background-color: #f2f0c7; + } + + .form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--bs-border-color); + } +} diff --git a/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx new file mode 100644 index 000000000..2e1d54fc5 --- /dev/null +++ b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx @@ -0,0 +1,226 @@ +import { startTransition, useCallback, useEffect, useState } from 'react' +import { Alert, Button, Col, Form, Row, Spinner } from '@iqss/dataverse-design-system' +import { Trans, useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { toast } from 'react-toastify' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { + DatasetNonNumericVersionSearchParam, + DatasetPublishingStatus +} from '@/dataset/domain/models/Dataset' +import { useGetGuestbooksByCollectionId } from '@/sections/guestbooks/useGetGuestbooksByCollectionId' +import { QueryParamKey, Route } from '@/sections/Route.enum' +import { useAssignDatasetGuestbook } from './useAssignDatasetGuestbook' +import { useDataset } from '../../dataset/DatasetContext' +import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' +import styles from './EditGuestbook.module.scss' + +interface EditGuestbookProps { + onPreview?: () => void +} + +export function EditGuestbook({ onPreview }: EditGuestbookProps) { + const { t } = useTranslation('dataset') + const { t: tShared } = useTranslation('shared') + const [selectedGuestbookId, setSelectedGuestbookId] = useState(undefined) + const [previewGuestbook, setPreviewGuestbook] = useState(undefined) + const { dataset, refreshDataset } = useDataset() + const navigate = useNavigate() + const collectionIdOrAlias = dataset?.parentCollectionNode?.id + const collectionName = dataset?.parentCollectionNode?.name ?? '' + + const navigateToDatasetView = useCallback(() => { + if (!dataset) return + + const searchParams = new URLSearchParams() + searchParams.set(QueryParamKey.PERSISTENT_ID, dataset.persistentId) + + if (dataset.version.publishingStatus === DatasetPublishingStatus.DRAFT) { + searchParams.set(QueryParamKey.VERSION, DatasetNonNumericVersionSearchParam.DRAFT) + } else { + searchParams.set(QueryParamKey.VERSION, dataset.version.number.toString()) + } + + navigate(`${Route.DATASETS}?${searchParams.toString()}`) + }, [dataset, navigate]) + + const { guestbooks, isLoadingGuestbooksByCollectionId, errorGetGuestbooksByCollectionId } = + useGetGuestbooksByCollectionId({ + collectionIdOrAlias + }) + const { + handleAssignDatasetGuestbook, + isLoadingAssignDatasetGuestbook, + errorAssignDatasetGuestbook + } = useAssignDatasetGuestbook({ + onSuccessfulAssignDatasetGuestbook: () => { + toast.success(t('alerts.termsUpdated.alertText')) + refreshDataset() + navigateToDatasetView() + } + }) + + useEffect(() => { + if (guestbooks.length === 0) { + setSelectedGuestbookId(undefined) + return + } + + const currentDatasetGuestbookId = dataset?.guestbookId + + if (currentDatasetGuestbookId === undefined) { + setSelectedGuestbookId((currentSelectedGuestbookId) => + currentSelectedGuestbookId !== undefined && + guestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) + ? currentSelectedGuestbookId + : undefined + ) + return + } + + const hasCurrentDatasetGuestbook = guestbooks.some( + (guestbook) => guestbook.id === currentDatasetGuestbookId + ) + + if (!hasCurrentDatasetGuestbook) { + setSelectedGuestbookId(undefined) + return + } + + setSelectedGuestbookId((currentSelectedGuestbookId) => { + if ( + currentSelectedGuestbookId !== undefined && + guestbooks.some((guestbook) => guestbook.id === currentSelectedGuestbookId) + ) { + return currentSelectedGuestbookId + } + + return currentDatasetGuestbookId + }) + }, [dataset?.guestbookId, guestbooks]) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + if (!dataset || selectedGuestbookId === undefined) { + return + } + await handleAssignDatasetGuestbook(dataset.id, selectedGuestbookId) + } + + return ( + + + + + {t('editTerms.guestbook.title')} + + + + + ) + }} + /> + {!isLoadingGuestbooksByCollectionId && + !errorGetGuestbooksByCollectionId && + guestbooks.length === 0 && ( + + {t('editTerms.guestbook.noGuestbooksEnabled', { collectionName })} + + )} + + + {isLoadingGuestbooksByCollectionId && ( + + + + )} + + {errorGetGuestbooksByCollectionId && ( + {errorGetGuestbooksByCollectionId} + )} + {errorAssignDatasetGuestbook && ( + {errorAssignDatasetGuestbook} + )} + + {!isLoadingGuestbooksByCollectionId && + !errorGetGuestbooksByCollectionId && + guestbooks.length > 0 && ( + + {guestbooks.map((guestbook) => ( + + setSelectedGuestbookId(guestbook.id)} + label={guestbook.name} + /> + + { + if (onPreview) { + onPreview() + return + } + startTransition(() => { + setPreviewGuestbook(guestbook) + }) + }} + aria-label={t('editTerms.guestbook.previewButton')}> + {t('editTerms.guestbook.previewButton')} + + + ))} + + )} + + + + + + {isLoadingAssignDatasetGuestbook ? tShared('saving') : tShared('saveChanges')} + + + {tShared('cancel')} + + + + + {previewGuestbook && ( + + startTransition(() => { + setPreviewGuestbook(undefined) + }) + } + guestbook={previewGuestbook} + /> + )} + + ) +} diff --git a/src/sections/edit-dataset-terms/edit-guestbook/useAssignDatasetGuestbook.tsx b/src/sections/edit-dataset-terms/edit-guestbook/useAssignDatasetGuestbook.tsx new file mode 100644 index 000000000..e8cadd7fd --- /dev/null +++ b/src/sections/edit-dataset-terms/edit-guestbook/useAssignDatasetGuestbook.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { assignDatasetGuestbook, WriteError } from '@iqss/dataverse-client-javascript' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +interface UseAssignDatasetGuestbookProps { + onSuccessfulAssignDatasetGuestbook: () => void +} + +interface UseAssignDatasetGuestbookReturn { + isLoadingAssignDatasetGuestbook: boolean + errorAssignDatasetGuestbook: string | null + handleAssignDatasetGuestbook: (datasetId: number | string, guestbookId: number) => Promise +} + +export const useAssignDatasetGuestbook = ({ + onSuccessfulAssignDatasetGuestbook +}: UseAssignDatasetGuestbookProps): UseAssignDatasetGuestbookReturn => { + const { t } = useTranslation('dataset') + const [isLoadingAssignDatasetGuestbook, setIsLoadingAssignDatasetGuestbook] = useState(false) + const [errorAssignDatasetGuestbook, setErrorAssignDatasetGuestbook] = useState( + null + ) + + const handleAssignDatasetGuestbook = async (datasetId: number | string, guestbookId: number) => { + setIsLoadingAssignDatasetGuestbook(true) + setErrorAssignDatasetGuestbook(null) + + try { + await assignDatasetGuestbook.execute(datasetId, guestbookId) + onSuccessfulAssignDatasetGuestbook() + } catch (err) { + if (err instanceof WriteError) { + const errorHandler = new JSDataverseWriteErrorHandler(err) + const formattedError = + errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() + setErrorAssignDatasetGuestbook(formattedError) + } else { + setErrorAssignDatasetGuestbook(t('editTerms.defaultGuestbookUpdateError')) + } + } finally { + setIsLoadingAssignDatasetGuestbook(false) + } + } + + return { + isLoadingAssignDatasetGuestbook, + errorAssignDatasetGuestbook, + handleAssignDatasetGuestbook + } +} diff --git a/src/sections/file/File.tsx b/src/sections/file/File.tsx index 29840f278..80da0e612 100644 --- a/src/sections/file/File.tsx +++ b/src/sections/file/File.tsx @@ -148,6 +148,10 @@ export function File({ {isTabular ? ( ) : ( ) => { + if (!hasGuestbook || downloadDisabled) { + return + } + + event.preventDefault() + setShowDownloadWithGuestbookModal(true) + } return ( - - {type.displayFormatIsUnknown - ? t('actions.accessFileMenu.downloadOptions.options.original') - : type.toDisplayFormat()} - + <> + + {type.displayFormatIsUnknown + ? t('actions.accessFileMenu.downloadOptions.options.original') + : type.toDisplayFormat()} + + setShowDownloadWithGuestbookModal(false)} + /> + > ) } diff --git a/src/sections/file/file-action-buttons/access-file-menu/FileTabularDownloadOptions.tsx b/src/sections/file/file-action-buttons/access-file-menu/FileTabularDownloadOptions.tsx index cb458dde8..a5c522df3 100644 --- a/src/sections/file/file-action-buttons/access-file-menu/FileTabularDownloadOptions.tsx +++ b/src/sections/file/file-action-buttons/access-file-menu/FileTabularDownloadOptions.tsx @@ -2,15 +2,28 @@ import { DropdownButtonItem } from '@iqss/dataverse-design-system' import { useDataset } from '../../../dataset/DatasetContext' import { useTranslation } from 'react-i18next' import { FileDownloadUrls, FileType } from '../../../../files/domain/models/FileMetadata' +import { DownloadWithGuestbookModal } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal' +import { MouseEvent, useState } from 'react' import FileTypeToFriendlyTypeMap from '../../../../files/domain/models/FileTypeToFriendlyTypeMap' +import { CustomTerms, DatasetLicense } from '@/dataset/domain/models/Dataset' interface FileTabularDownloadOptionsProps { + fileId: number + guestbookId?: number + datasetPersistentId?: string + datasetLicense?: DatasetLicense + datasetCustomTerms?: CustomTerms type: FileType ingestInProgress: boolean downloadUrls: FileDownloadUrls } export function FileTabularDownloadOptions({ + fileId, + guestbookId, + datasetPersistentId, + datasetLicense, + datasetCustomTerms, type, ingestInProgress, downloadUrls @@ -18,22 +31,57 @@ export function FileTabularDownloadOptions({ const { t } = useTranslation('files') const { dataset } = useDataset() const downloadDisabled = ingestInProgress || (dataset && dataset.isLockedFromFileDownload) + const resolvedGuestbookId = guestbookId ?? dataset?.guestbookId + const hasGuestbook = resolvedGuestbookId !== undefined + + const [showDownloadWithGuestbookModal, setShowDownloadWithGuestbookModal] = useState(false) + + const handleDownloadClick = (event: MouseEvent) => { + if (!hasGuestbook || downloadDisabled) { + return + } + + event.preventDefault() + setShowDownloadWithGuestbookModal(true) + } + + const handleCloseGuestbookModal = () => { + setShowDownloadWithGuestbookModal(false) + } return ( <> {!type.originalFormatIsUnknown && ( - {`${ - type.original || '' - } (${t('actions.accessFileMenu.downloadOptions.options.original')})`} + {`${type.original || ''} (${t( + 'actions.accessFileMenu.downloadOptions.options.original' + )})`} )} - + {t('actions.accessFileMenu.downloadOptions.options.tabular')} {type.original !== FileTypeToFriendlyTypeMap['application/x-r-data'] && ( - + {t('actions.accessFileMenu.downloadOptions.options.RData')} )} + > ) } diff --git a/src/sections/guestbooks/preview-modal/PreviewGuestbookModal.tsx b/src/sections/guestbooks/preview-modal/PreviewGuestbookModal.tsx new file mode 100644 index 000000000..09f76c49c --- /dev/null +++ b/src/sections/guestbooks/preview-modal/PreviewGuestbookModal.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Col, Modal, QuestionMarkTooltip, Row } from '@iqss/dataverse-design-system' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import styles from '@/sections/dataset/dataset-terms/DatasetTerms.module.scss' + +interface PreviewGuestbookModalProps { + show: boolean + handleClose: () => void + guestbook: Guestbook +} + +interface GuestbookItems { + label: string + required: boolean +} + +export const PreviewGuestbookModal = ({ + show, + handleClose, + guestbook +}: PreviewGuestbookModalProps) => { + const { t } = useTranslation('guestbooks') + const { t: tShared } = useTranslation('shared') + + const guestbookData = useMemo(() => { + return [ + { label: t('create.fields.dataCollected.options.email'), required: guestbook.emailRequired }, + { label: t('create.fields.dataCollected.options.name'), required: guestbook.nameRequired }, + { + label: t('create.fields.dataCollected.options.institution'), + required: guestbook.institutionRequired + }, + { + label: t('create.fields.dataCollected.options.position'), + required: guestbook.positionRequired + } + ] + }, [guestbook, t]) + + return ( + + + {t('preview.title')} + + + {t('preview.description')} + + + + {t('preview.guestbookNameLabel')} + + {guestbook.name} + + + + {t('preview.guestbookDataLabel')} + + + + <>{t('preview.accountInformation')}> + + {guestbookData.map((item) => ( + + {item.label} ({item.required ? t('preview.required') : t('preview.optional')}) + + ))} + + {guestbook.customQuestions && guestbook.customQuestions.length > 0 && ( + <> + <>{t('preview.customQuestionsLabel')}> + + {guestbook.customQuestions.map((question, index) => ( + + {question.question} ( + {question.required ? t('preview.required') : t('preview.optional')}) + + ))} + + > + )} + + + + + + {tShared('close')} + + + + ) +} diff --git a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx new file mode 100644 index 000000000..f3d00ffb6 --- /dev/null +++ b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react' +import { ReadError, getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +interface UseGetGuestbooksByCollectionIdProps { + collectionIdOrAlias?: number | string + autoFetch?: boolean +} + +export const useGetGuestbooksByCollectionId = ({ + collectionIdOrAlias, + autoFetch = true +}: UseGetGuestbooksByCollectionIdProps) => { + const [guestbooks, setGuestbooks] = useState([]) + const [isLoadingGuestbooksByCollectionId, setIsLoadingGuestbooksByCollectionId] = + useState(autoFetch) + const [errorGetGuestbooksByCollectionId, setErrorGetGuestbooksByCollectionId] = useState< + string | null + >(null) + + const fetchGuestbooksByCollectionId = useCallback(async () => { + if (collectionIdOrAlias === undefined) { + setGuestbooks([]) + setIsLoadingGuestbooksByCollectionId(false) + setErrorGetGuestbooksByCollectionId(null) + return + } + + setIsLoadingGuestbooksByCollectionId(true) + setErrorGetGuestbooksByCollectionId(null) + + try { + const fetchedGuestbooks = await getGuestbooksByCollectionId.execute(collectionIdOrAlias) + setGuestbooks(Array.isArray(fetchedGuestbooks) ? fetchedGuestbooks : []) + } catch (err) { + setGuestbooks([]) + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + setErrorGetGuestbooksByCollectionId(formattedError) + } else { + setErrorGetGuestbooksByCollectionId( + 'Something went wrong getting guestbooks by collection id. Try again later.' + ) + } + } finally { + setIsLoadingGuestbooksByCollectionId(false) + } + }, [collectionIdOrAlias]) + + useEffect(() => { + if (autoFetch) { + void fetchGuestbooksByCollectionId() + } + }, [autoFetch, fetchGuestbooksByCollectionId]) + + return { + guestbooks, + isLoadingGuestbooksByCollectionId, + errorGetGuestbooksByCollectionId, + fetchGuestbooksByCollectionId + } +} diff --git a/src/stories/dataset/dataset-guestbook/DatasetGuestbook.stories.tsx b/src/stories/dataset/dataset-guestbook/DatasetGuestbook.stories.tsx new file mode 100644 index 000000000..89b9f4ea0 --- /dev/null +++ b/src/stories/dataset/dataset-guestbook/DatasetGuestbook.stories.tsx @@ -0,0 +1,71 @@ +import { Meta, StoryObj } from '@storybook/react' +import { DatasetGuestbook } from '@/sections/dataset/dataset-guestbook/DatasetGuestbook' +import { WithI18next } from '@/stories/WithI18next' +import { DatasetContext } from '@/sections/dataset/DatasetContext' +import { DatasetMother } from '@tests/component/dataset/domain/models/DatasetMother' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' + +class GuestbookMockRepository implements GuestbookRepository { + getGuestbook(_guestbookId: number): Promise { + return Promise.resolve({ + id: 3, + name: 'Storybook Guestbook', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + } +} + +const meta: Meta = { + title: 'Sections/Dataset Page/DatasetTerms/DatasetGuestbook', + component: DatasetGuestbook, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +const withDatasetContext = (datasetWithGuestbook: boolean) => { + const DatasetGuestbookStoryDecorator = (StoryComponent: () => JSX.Element) => ( + {} + }}> + + + ) + + DatasetGuestbookStoryDecorator.displayName = `DatasetGuestbookStoryDecorator-${String( + datasetWithGuestbook + )}` + return DatasetGuestbookStoryDecorator +} + +export const WithAssignedGuestbook: Story = { + decorators: [withDatasetContext(true)], + render: () => +} + +export const WithoutAssignedGuestbook: Story = { + decorators: [withDatasetContext(false)], + render: () => +} diff --git a/src/stories/dataset/dataset-terms/DatasetTerms.stories.tsx b/src/stories/dataset/dataset-terms/DatasetTerms.stories.tsx index e882ab743..864d8bbca 100644 --- a/src/stories/dataset/dataset-terms/DatasetTerms.stories.tsx +++ b/src/stories/dataset/dataset-terms/DatasetTerms.stories.tsx @@ -8,6 +8,9 @@ import { LicenseMother } from '../../../../tests/component/dataset/domain/models import { TermsOfUseMother } from '../../../../tests/component/dataset/domain/models/TermsOfUseMother' import { FileMockRestrictedFilesRepository } from '@/stories/file/FileMockRestrictedFilesRepository' import { FileMockNoRestrictedFilesRepository } from '@/stories/file/FileMockNoRestrictedFilesRepository' +import { DatasetContext } from '@/sections/dataset/DatasetContext' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' const meta: Meta = { title: 'Sections/Dataset Page/DatasetTerms', @@ -21,8 +24,51 @@ type Story = StoryObj const testDataset = DatasetMother.createRealistic() const license = LicenseMother.create() const termsOfUseWithoutCustomTerms = TermsOfUseMother.createRealistic({ customTerms: undefined }) +const testDatasetWithGuestbook = DatasetMother.createRealistic({ guestbookId: 3 }) + +class GuestbookMockRepository implements GuestbookRepository { + getGuestbook(_guestbookId: number): Promise { + return Promise.resolve({ + id: 3, + name: 'Storybook Guestbook', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + } +} + +const withDatasetContext = (dataset = testDatasetWithGuestbook) => { + const DatasetTermsStoryDecorator = (Story: () => JSX.Element) => ( + {} + }}> + + + ) + + DatasetTermsStoryDecorator.displayName = 'DatasetTermsStoryDecorator' + return DatasetTermsStoryDecorator +} export const Default: Story = { + decorators: [withDatasetContext()], render: () => ( ) } @@ -47,6 +94,7 @@ export const Loading: Story = { } export const RestrictedFiles: Story = { + decorators: [withDatasetContext()], render: () => ( ) } export const NoRestrictedFiles: Story = { + decorators: [withDatasetContext()], render: () => ( ) } export const CustomTerms: Story = { + decorators: [withDatasetContext()], render: () => ( + ) +} + +export const WithoutAssignedGuestbook: Story = { + decorators: [withDatasetContext(DatasetMother.createRealistic({ guestbookId: undefined }))], + render: () => ( + + ) +} + +export const GuestbookEmptyState: Story = { + decorators: [withDatasetContext(DatasetMother.createRealistic({ guestbookId: undefined }))], + render: () => ( + ) } diff --git a/src/stories/edit-dataset-terms/EditDatasetTerms.stories.tsx b/src/stories/edit-dataset-terms/EditDatasetTerms.stories.tsx index 5d7a7b033..831adff09 100644 --- a/src/stories/edit-dataset-terms/EditDatasetTerms.stories.tsx +++ b/src/stories/edit-dataset-terms/EditDatasetTerms.stories.tsx @@ -44,10 +44,10 @@ export const EditTermsOfAccessTab: Story = { ) } -export const EditGuestBookTab: Story = { +export const EditGuestbookTab: Story = { render: () => ( diff --git a/src/stories/guestbooks/guestbook-applied-modal/DownloadWithGuestbookModal.stories.tsx b/src/stories/guestbooks/guestbook-applied-modal/DownloadWithGuestbookModal.stories.tsx new file mode 100644 index 000000000..dfef65ee6 --- /dev/null +++ b/src/stories/guestbooks/guestbook-applied-modal/DownloadWithGuestbookModal.stories.tsx @@ -0,0 +1,90 @@ +import { Meta, StoryObj } from '@storybook/react' +import { DownloadWithGuestbookModal } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal' +import { WithI18next } from '@/stories/WithI18next' +import { WithLoggedInUser } from '@/stories/WithLoggedInUser' +import { DatasetContext } from '@/sections/dataset/DatasetContext' +import { DatasetMother } from '@tests/component/dataset/domain/models/DatasetMother' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' + +class GuestbookMockRepository implements GuestbookRepository { + getGuestbook(_guestbookId: number): Promise { + return Promise.resolve({ + id: 3, + name: 'Storybook Guestbook', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + } +} + +class AccessMockRepository implements AccessRepository { + submitGuestbookForDatasetDownload( + _datasetId: number | string, + _answers: Array<{ id: number | string; value: string | string[] }> + ): Promise { + return Promise.resolve('/api/v1/access/dataset/:persistentId?token=storybook') + } + + submitGuestbookForDatafileDownload( + _fileId: number | string, + _answers: Array<{ id: number | string; value: string | string[] }> + ): Promise { + return Promise.resolve('/api/v1/access/datafile/123?token=storybook') + } + + submitGuestbookForDatafilesDownload( + _fileIds: Array, + _answers: Array<{ id: number | string; value: string | string[] }> + ): Promise { + return Promise.resolve('/api/v1/access/datafiles/123,124?token=storybook') + } +} + +const meta: Meta = { + title: 'Sections/Guestbooks/DownloadWithGuestbookModal', + component: DownloadWithGuestbookModal, + decorators: [ + WithI18next, + WithLoggedInUser, + (Story) => ( + {} + }}> + + + ) + ] +} + +export default meta +type Story = StoryObj + +export const SingleFile: Story = { + args: { + show: true, + handleClose: () => {}, + fileId: 123, + guestbookId: 3, + guestbookRepository: new GuestbookMockRepository(), + accessRepository: new AccessMockRepository() + } +} diff --git a/src/stories/guestbooks/preview-modal/PreviewGuestbookModal.stories.tsx b/src/stories/guestbooks/preview-modal/PreviewGuestbookModal.stories.tsx new file mode 100644 index 000000000..fa17cdc12 --- /dev/null +++ b/src/stories/guestbooks/preview-modal/PreviewGuestbookModal.stories.tsx @@ -0,0 +1,49 @@ +import { Meta, StoryObj } from '@storybook/react' +import { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' +import { WithI18next } from '@/stories/WithI18next' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' + +const mockGuestbook: Guestbook = { + id: 3, + name: 'Storybook Guestbook', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + }, + { + question: 'Do you plan to cite this dataset?', + required: false, + displayOrder: 2, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +const meta: Meta = { + title: 'Sections/Guestbooks/PreviewGuestbookModal', + component: PreviewGuestbookModal, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + show: true, + handleClose: () => {}, + guestbook: mockGuestbook + } +} diff --git a/tests/component/dataset/domain/models/DatasetMother.ts b/tests/component/dataset/domain/models/DatasetMother.ts index 24f1d697f..bdfcc892a 100644 --- a/tests/component/dataset/domain/models/DatasetMother.ts +++ b/tests/component/dataset/domain/models/DatasetMother.ts @@ -451,7 +451,8 @@ export class DatasetMother { dataset.nextMajorVersion, dataset.nextMinorVersion, dataset.requiresMajorVersionUpdate, - dataset.fileStore + dataset.fileStore, + dataset.guestbookId ).build() } diff --git a/tests/component/dataset/infrastructure/mappers/JSDatasetMapper.spec.ts b/tests/component/dataset/infrastructure/mappers/JSDatasetMapper.spec.ts index 583db8025..37521a4a2 100644 --- a/tests/component/dataset/infrastructure/mappers/JSDatasetMapper.spec.ts +++ b/tests/component/dataset/infrastructure/mappers/JSDatasetMapper.spec.ts @@ -49,6 +49,7 @@ const jsDataset = { deaccessionNote: undefined }, internalVersionNumber: 1, + guestbookId: 1001, termsOfUse: { termsOfAccess: termsOfAccess }, @@ -231,6 +232,7 @@ const expectedDataset = { internalVersionNumber: 1, requestedVersion: undefined, publicationDate: undefined, + guestbookId: 1001, alerts: [{ variant: 'warning', messageKey: 'draftVersion', dynamicFields: undefined }], summaryFields: [ { @@ -339,6 +341,7 @@ const expectedDatasetWithPublicationDate = { internalVersionNumber: 1, requestedVersion: undefined, publicationDate: undefined, + guestbookId: 1001, alerts: [{ variant: 'warning', messageKey: 'draftVersion', dynamicFields: undefined }], summaryFields: [ { @@ -448,6 +451,7 @@ const expectedDatasetWithNextVersionNumbers = { internalVersionNumber: 1, requestedVersion: undefined, publicationDate: undefined, + guestbookId: 1001, alerts: [{ variant: 'warning', messageKey: 'draftVersion', dynamicFields: undefined }], summaryFields: [ { @@ -561,6 +565,7 @@ const expectedDatasetAlternateVersion = { internalVersionNumber: 1, requestedVersion: '4.0', publicationDate: undefined, + guestbookId: 1001, hasValidTermsOfAccess: true, hasOneTabularFileAtLeast: true, isValid: true, @@ -670,6 +675,34 @@ describe('JS Dataset Mapper', () => { expect(mapped).to.deep.equal(expectedDataset) }) + it('maps guestbookId when present in jsDataset', () => { + const mapped = JSDatasetMapper.toDataset( + jsDataset, + citation, + datasetSummaryFields, + jsDatasetPermissions, + jsDatasetLocks, + jsDatasetFilesTotalOriginalDownloadSize, + jsDatasetFilesTotalArchivalDownloadSize + ) + + expect(mapped.guestbookId).to.equal(1001) + }) + + it('does not set guestbookId when jsDataset has no guestbook', () => { + const mapped = JSDatasetMapper.toDataset( + { ...jsDataset, guestbookId: undefined }, + citation, + datasetSummaryFields, + jsDatasetPermissions, + jsDatasetLocks, + jsDatasetFilesTotalOriginalDownloadSize, + jsDatasetFilesTotalArchivalDownloadSize + ) + + expect(mapped.guestbookId).to.equal(undefined) + }) + it('maps jsDataset model to the domain Dataset model for alternate version', () => { const mappedWithAlternate = JSDatasetMapper.toDataset( jsDataset, diff --git a/tests/component/sections/dataset/Dataset.spec.tsx b/tests/component/sections/dataset/Dataset.spec.tsx index e2a3107ef..4d1960e1e 100644 --- a/tests/component/sections/dataset/Dataset.spec.tsx +++ b/tests/component/sections/dataset/Dataset.spec.tsx @@ -451,6 +451,7 @@ describe('Dataset', () => { termsTab.click() cy.findByText('Dataset Terms').should('exist') + cy.findByTestId('dataset-guestbook-section').should('exist') }) it('renders the Dataset Files tab', () => { diff --git a/tests/component/sections/dataset/dataset-action-buttons/DatasetActionButtons.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/DatasetActionButtons.spec.tsx index 3a38c58fa..beb90e287 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/DatasetActionButtons.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/DatasetActionButtons.spec.tsx @@ -34,7 +34,7 @@ describe('DatasetActionButtons', () => { ) cy.findByRole('group', { name: 'Dataset Action Buttons' }).should('exist') - cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') // TODO: change this to 'exist' when access datafile supports bearer tokens, downloading of files temporary disabled for draft datasets + cy.findByRole('button', { name: 'Access Dataset' }).should('exist') cy.findByRole('button', { name: 'Publish Dataset' }).should('exist') cy.findByRole('button', { name: 'Edit Dataset' }).should('exist') cy.findByRole('button', { name: 'Link Dataset' }).should('exist') @@ -65,7 +65,7 @@ describe('DatasetActionButtons', () => { ) cy.findByRole('group', { name: 'Dataset Action Buttons' }).should('exist') - cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') // TODO: change this to 'exist' when access datafile supports bearer tokens, downloading of files temporary disabled for draft datasets + cy.findByRole('button', { name: 'Access Dataset' }).should('exist') cy.findByRole('button', { name: 'Submit for Review' }).should('exist') cy.findByRole('button', { name: 'Edit Dataset' }).should('exist') cy.findByRole('button', { name: 'Link Dataset' }).should('exist') diff --git a/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx index 5896057f7..c83b395a0 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx @@ -100,6 +100,46 @@ describe('DatasetToolOptions', () => { }) }) + it('calls getDatasetExternalToolResolved with preview=false and locale', () => { + testExternalToolsRepository.getDatasetExternalToolResolved = cy + .stub() + .as('getDatasetExternalToolResolved') + .resolves( + DatasetExternalToolResolvedMother.create({ + toolUrlResolved: 'https://example.com/external-tool' + }) + ) + + const fakeWindow = { + closed: false, + document: { title: '' }, + location: { href: '' }, + close: cy.stub().as('windowCloseStub') + } + + cy.window().then((win) => { + cy.stub(win, 'open').as('windowOpen').returns(fakeWindow) + }) + + cy.customMount( + + + + ) + + cy.findByText('Dataset Explore Tool').click() + + cy.get('@getDatasetExternalToolResolved') + .should('have.been.calledOnce') + .its('firstCall.args') + .then((args) => { + expect(args[0]).to.equal('some-persistent-id') + expect(args[1]).to.equal(testDatasetExploreTool.id) + expect(args[2]).to.have.property('preview', false) + expect(args[2]).to.have.property('locale').that.is.a('string').and.is.not.empty + }) + }) + it('shows an error toast if fetching the tool URL fails', () => { testExternalToolsRepository.getDatasetExternalToolResolved = cy .stub() @@ -134,6 +174,37 @@ describe('DatasetToolOptions', () => { }) }) + it('does not close popup when it is already closed during error handling', () => { + const fakeWindow = { + closed: false, + document: { title: '' }, + location: { href: '' }, + close: cy.stub().as('windowCloseStub') + } + + testExternalToolsRepository.getDatasetExternalToolResolved = cy.stub().callsFake(() => { + fakeWindow.closed = true + return Promise.reject(new Error('Failed to fetch tool URL')) + }) + + cy.window().then((win) => { + cy.stub(win, 'open').as('windowOpen').returns(fakeWindow) + }) + + cy.customMount( + + + + ) + + cy.findByText('Dataset Explore Tool').click() + + cy.findByText(/There was a problem opening the external tool. Please try again./).should( + 'exist' + ) + cy.get('@windowCloseStub').should('not.have.been.called') + }) + it('does not open multiple windows if the tool option is clicked rapidly', () => { const mockDatasetToolResolvedUrl = 'https://example.com/external-tool' diff --git a/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx index e088fc0d8..a0b9cc211 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx @@ -6,10 +6,17 @@ import { DatasetVersionMother } from '../../../../dataset/domain/models/DatasetMother' import { FileSizeUnit } from '../../../../../../src/files/domain/models/FileMetadata' +import { FileRepository } from '../../../../../../src/files/domain/repositories/FileRepository' +import { getGuestbook, submitGuestbookForDatasetDownload } from '@iqss/dataverse-client-javascript' const downloadUrls = DatasetDownloadUrlsMother.create() +const fileRepository: FileRepository = {} as FileRepository describe('AccessDatasetMenu', () => { + beforeEach(() => { + fileRepository.getAllByDatasetPersistentId = cy.stub().resolves([{ id: 10 }, { id: 11 }]) + }) + it('renders the AccessDatasetMenu if the user has download files permissions and the dataset is not deaccessioned', () => { const version = DatasetVersionMother.createReleased() const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() @@ -98,6 +105,32 @@ describe('AccessDatasetMenu', () => { cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') }) + it('does not render when dataset is deaccessioned and canUpdateDataset is false even if download is allowed', () => { + const version = DatasetVersionMother.createDeaccessioned() + const permissions = DatasetPermissionsMother.create({ + canUpdateDataset: false, + canDownloadFiles: true + }) + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal(), + DatasetFileDownloadSizeMother.createArchival() + ] + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') + }) + it('displays one dropdown option if there are no tabular files', () => { const version = DatasetVersionMother.createReleased() const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() @@ -122,6 +155,29 @@ describe('AccessDatasetMenu', () => { .should('have.attr', 'href', downloadUrls.original) }) + it('renders empty size text in non-tabular mode when original size is missing', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createArchival({ value: 4000, unit: FileSizeUnit.BYTES }) + ] + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByText('Download ZIP ()').should('exist') + }) + it('displays two dropdown options if there is at least one', () => { const version = DatasetVersionMother.createReleased() const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() @@ -153,6 +209,29 @@ describe('AccessDatasetMenu', () => { .should('have.attr', 'href', downloadUrls.archival) }) + it('renders empty size text when a download mode size is missing', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }) + ] + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByText('Original Format ZIP (2 KB)').should('exist') + cy.findByText('Archival Format (.tab) ZIP ()').should('exist') + }) + it('does not render the AccessDatasetMenu if the file download sizes are zero', () => { const version = DatasetVersionMother.createReleased() const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() @@ -197,4 +276,198 @@ describe('AccessDatasetMenu', () => { ) cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') }) + + it('opens DownloadWithGuestbookModal when guestbook exists and download option is clicked', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }) + ] + cy.stub(getGuestbook, 'execute').resolves({ + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + const submitGuestbookForDatasetDownloadExecute = cy + .stub(submitGuestbookForDatasetDownload, 'execute') + .resolves('/api/v1/access/dataset/test-token') + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click') + }) + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByRole('button', { name: /Download ZIP/ }).click() + cy.findByRole('dialog').should('exist') + cy.findByLabelText(/name/i).type('Test User') + cy.findByLabelText(/email/i).type('test.user@example.com') + cy.findByRole('button', { name: 'Accept' }).click() + cy.wrap(submitGuestbookForDatasetDownloadExecute).should('have.been.calledOnce') + }) + + it('renders download option as a button and opens guestbook modal when guestbook exists', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }) + ] + + cy.stub(getGuestbook, 'execute').resolves({ + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByRole('button', { name: /Download ZIP/ }) + .should('exist') + .click() + cy.findByRole('dialog').should('exist') + }) + + it('closes DownloadWithGuestbookModal when cancel is clicked', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }) + ] + + cy.stub(getGuestbook, 'execute').resolves({ + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByRole('button', { name: /Download ZIP/ }).click() + cy.findByRole('dialog').should('exist') + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByRole('dialog').should('not.exist') + }) + + it('opens guestbook modal from archival option when guestbook exists', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }), + DatasetFileDownloadSizeMother.createArchival({ value: 4000, unit: FileSizeUnit.BYTES }) + ] + + cy.stub(getGuestbook, 'execute').resolves({ + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByRole('button', { name: /Archival Format \(\.tab\) ZIP/ }).click() + cy.findByRole('dialog').should('exist') + }) + + it('keeps archival option as link when guestbook does not exist', () => { + const version = DatasetVersionMother.createReleased() + const permissions = DatasetPermissionsMother.createWithFilesDownloadAllowed() + const fileDownloadSizes = [ + DatasetFileDownloadSizeMother.createOriginal({ value: 2000, unit: FileSizeUnit.BYTES }), + DatasetFileDownloadSizeMother.createArchival({ value: 4000, unit: FileSizeUnit.BYTES }) + ] + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Access Dataset' }).click() + cy.findByRole('link', { name: /Archival Format \(\.tab\) ZIP/ }).should( + 'have.attr', + 'href', + downloadUrls.archival + ) + }) }) diff --git a/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/DeaccessionDatasetButton.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/DeaccessionDatasetButton.spec.tsx index 52951c6c9..72bb9f1a7 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/DeaccessionDatasetButton.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/DeaccessionDatasetButton.spec.tsx @@ -73,6 +73,18 @@ describe('DeaccessionDatasetButton', () => { cy.findByRole('button', { name: 'Deaccession Dataset' }).should('not.exist') }) + it('does not render when dataset is not released and user cannot publish', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithPublishingDatasetNotAllowed(), + version: DatasetVersionMother.createNotReleased() + }) + + cy.customMount() + + cy.findByRole('separator').should('not.exist') + cy.findByRole('button', { name: 'Deaccession Dataset' }).should('not.exist') + }) + describe('Tests the deaccession modal', () => { it('renders the DeaccessionDatasetButton and opens the modal on click', () => { const dataset = DatasetMother.create({ @@ -92,6 +104,38 @@ describe('DeaccessionDatasetButton', () => { cy.get('textarea').should('exist') }) + it('stops propagation when opening deaccession modal', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithPublishingDatasetAllowed(), + version: DatasetVersionMother.createReleased() + }) + const parentClick = cy.stub().as('parentClick') + + cy.customMount( + + + + ) + + cy.findByRole('button', { name: 'Deaccession Dataset' }).click() + cy.get('@parentClick').should('not.have.been.called') + cy.findByRole('dialog').should('exist') + }) + + it('closes deaccession modal when cancel is clicked', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithPublishingDatasetAllowed(), + version: DatasetVersionMother.createReleased() + }) + + cy.customMount() + + cy.findByRole('button', { name: 'Deaccession Dataset' }).click() + cy.findByRole('dialog').should('exist') + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByRole('dialog').should('not.exist') + }) + it('displays the confirm modal when the deaccession modal is submitted', () => { const dataset = DatasetMother.create({ permissions: DatasetPermissionsMother.createWithPublishingDatasetAllowed(), diff --git a/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/delete-draft-dataset/useDeleteDraftDataset.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/delete-draft-dataset/useDeleteDraftDataset.spec.tsx new file mode 100644 index 000000000..5acf82854 --- /dev/null +++ b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/delete-draft-dataset/useDeleteDraftDataset.spec.tsx @@ -0,0 +1,129 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { useDeleteDraftDataset } from '@/sections/dataset/dataset-action-buttons/edit-dataset-menu/delete-draft-dataset/useDeleteDraftDataset' +import { Utils } from '@/shared/helpers/Utils' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +describe('useDeleteDraftDataset', () => { + let datasetRepository: DatasetRepository + let onSuccessfulDelete: () => void + + beforeEach(() => { + datasetRepository = {} as DatasetRepository + onSuccessfulDelete = cy.stub().as('onSuccessfulDelete') + }) + + it('should initialize with default state', async () => { + const { result } = renderHook(() => + useDeleteDraftDataset({ + datasetRepository, + onSuccessfulDelete + }) + ) + + await waitFor(() => { + expect(result.current.isDeletingDataset).to.deep.equal(false) + expect(result.current.errorDeletingDataset).to.deep.equal(null) + expect(typeof result.current.handleDeleteDraftDataset).to.deep.equal('function') + }) + }) + + it('should delete draft dataset successfully and call onSuccessfulDelete after sleep', async () => { + datasetRepository.deleteDatasetDraft = cy.stub().resolves(undefined) + const sleepStub = cy.stub(Utils, 'sleep').resolves() + + const { result } = renderHook(() => + useDeleteDraftDataset({ + datasetRepository, + onSuccessfulDelete + }) + ) + + await act(async () => { + await result.current.handleDeleteDraftDataset(123) + }) + + expect(datasetRepository.deleteDatasetDraft).to.have.been.calledWith(123) + expect(sleepStub).to.have.been.calledWith(3000) + expect(onSuccessfulDelete).to.have.been.calledOnce + expect(result.current.isDeletingDataset).to.deep.equal(false) + expect(result.current.errorDeletingDataset).to.deep.equal(null) + }) + + describe('Error handling', () => { + it('should set formatted error from WriteError reason without status code', async () => { + const writeError = new WriteError() + datasetRepository.deleteDatasetDraft = cy.stub().rejects(writeError) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getReasonWithoutStatusCode').returns( + 'Formatted write error' + ) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback write error' + ) + + const { result } = renderHook(() => + useDeleteDraftDataset({ + datasetRepository, + onSuccessfulDelete + }) + ) + + await act(async () => { + await result.current.handleDeleteDraftDataset(123) + }) + + expect(datasetRepository.deleteDatasetDraft).to.have.been.calledWith(123) + expect(onSuccessfulDelete).to.not.have.been.called + expect(result.current.isDeletingDataset).to.deep.equal(false) + expect(result.current.errorDeletingDataset).to.deep.equal('Formatted write error') + }) + + it('should fall back to WriteError handler error message when reason is null', async () => { + const writeError = new WriteError() + datasetRepository.deleteDatasetDraft = cy.stub().rejects(writeError) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getReasonWithoutStatusCode').returns(null) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback write error' + ) + + const { result } = renderHook(() => + useDeleteDraftDataset({ + datasetRepository, + onSuccessfulDelete + }) + ) + + await act(async () => { + await result.current.handleDeleteDraftDataset(123) + }) + + expect(datasetRepository.deleteDatasetDraft).to.have.been.calledWith(123) + expect(onSuccessfulDelete).to.not.have.been.called + expect(result.current.isDeletingDataset).to.deep.equal(false) + expect(result.current.errorDeletingDataset).to.deep.equal('Fallback write error') + }) + + it('should set default translated error message for unknown errors', async () => { + datasetRepository.deleteDatasetDraft = cy.stub().rejects(new Error('unknown error')) + + const { result } = renderHook(() => + useDeleteDraftDataset({ + datasetRepository, + onSuccessfulDelete + }) + ) + + await act(async () => { + await result.current.handleDeleteDraftDataset(123) + }) + + expect(datasetRepository.deleteDatasetDraft).to.have.been.calledWith(123) + expect(onSuccessfulDelete).to.not.have.been.called + expect(result.current.isDeletingDataset).to.deep.equal(false) + expect(result.current.errorDeletingDataset).to.deep.equal( + 'An error occurred while deleting the dataset.' + ) + }) + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.spec.tsx b/tests/component/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.spec.tsx index 1eb6adc54..15a82954c 100644 --- a/tests/component/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.spec.tsx +++ b/tests/component/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.spec.tsx @@ -1,4 +1,8 @@ import { ReactNode } from 'react' +import { + getGuestbook, + submitGuestbookForDatafilesDownload +} from '@iqss/dataverse-client-javascript' import { Dataset as DatasetModel } from '../../../../../../../../src/dataset/domain/models/Dataset' import { DatasetProvider } from '../../../../../../../../src/sections/dataset/DatasetProvider' import { DatasetRepository } from '../../../../../../../../src/dataset/domain/repositories/DatasetRepository' @@ -296,6 +300,112 @@ describe('DownloadFilesButton', () => { ) }) + it('opens guestbook modal when guestbook exists and files are selected for non-tabular download', () => { + const datasetWithGuestbook = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithFilesDownloadAllowed(), + hasOneTabularFileAtLeast: false, + guestbookId: 10 + }) + const files = FilePreviewMother.createMany(2, { + metadata: FileMetadataMother.createNonTabular() + }) + const fileSelection = { + 'some-file-id': files[0] + } + + cy.mountAuthenticated( + withDataset( + , + datasetWithGuestbook + ) + ) + + cy.get('#download-files').parents('a').should('not.exist') + cy.get('#download-files').click() + cy.findByRole('dialog').should('exist') + cy.findByRole('button', { name: 'Accept' }).should('exist') + }) + + it('opens guestbook modal when guestbook exists and tabular option is clicked', () => { + const datasetWithGuestbook = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithFilesDownloadAllowed(), + hasOneTabularFileAtLeast: true, + guestbookId: 10 + }) + const files = FilePreviewMother.createMany(2, { + metadata: FileMetadataMother.createTabular() + }) + const fileSelection = { + 'some-file-id': files[0] + } + + cy.mountAuthenticated( + withDataset( + , + datasetWithGuestbook + ) + ) + + cy.get('#download-files').click() + cy.findByRole('button', { name: 'Original Format' }).click() + cy.findByRole('dialog').should('exist') + cy.findByRole('button', { name: 'Accept' }).should('exist') + }) + + it('submits guestbook for all selected files when guestbook exists', () => { + const files = [ + FilePreviewMother.create({ id: 10, metadata: FileMetadataMother.createTabular() }), + FilePreviewMother.create({ id: 11, metadata: FileMetadataMother.createTabular() }), + FilePreviewMother.create({ id: 12, metadata: FileMetadataMother.createTabular() }) + ] + const datasetWithGuestbook = DatasetMother.create({ + permissions: DatasetPermissionsMother.createWithFilesDownloadAllowed(), + hasOneTabularFileAtLeast: true, + guestbookId: 10 + }) + const fileSelection = { + 'file-a': undefined, + 'file-b': undefined, + 'file-c': undefined + } + + cy.stub(getGuestbook, 'execute').resolves({ + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + const submitStub = cy + .stub(submitGuestbookForDatafilesDownload, 'execute') + .resolves('/api/v1/access/datafiles/10,11,12?token=test') + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click').as('anchorClick') + }) + + cy.mountAuthenticated( + withDataset( + , + datasetWithGuestbook + ) + ) + + cy.get('#download-files').click() + cy.findByRole('button', { name: 'Original Format' }).click() + cy.findByLabelText(/^Name/).should('be.disabled') + cy.findByLabelText(/^Email/).should('be.disabled') + cy.findByRole('button', { name: 'Accept' }).click() + + cy.wrap(submitStub).should('have.been.calledOnce') + cy.wrap(submitStub).its('firstCall.args.0').should('deep.equal', [10, 11, 12]) + cy.get('@anchorClick').should('have.been.calledOnce') + }) + it('does not render the AccessDatasetMenu if the file store does not start with "s3"', () => { const datasetWithDownloadFilesPermission = DatasetMother.create({ permissions: DatasetPermissionsMother.createWithFilesDownloadAllowed(), diff --git a/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.spec.tsx b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.spec.tsx new file mode 100644 index 000000000..404945c42 --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission.spec.tsx @@ -0,0 +1,248 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { useGuestbookAppliedSubmission } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useGuestbookAppliedSubmission' + +const accessRepository: AccessRepository = {} as AccessRepository +const answers = [{ id: 'name', value: 'Test User' }] + +const guestbook: Guestbook = { + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +describe('useGuestbookAppliedSubmission', () => { + beforeEach(() => { + accessRepository.submitGuestbookForDatafileDownload = cy.stub().resolves('signed-url-datafile') + accessRepository.submitGuestbookForDatafilesDownload = cy + .stub() + .resolves('signed-url-datafiles') + }) + + it('initializes with default state', async () => { + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose: cy.stub().as('handleClose'), + accessRepository, + triggerDirectDownload: cy.stub().resolves(undefined) + }) + ) + + await waitFor(() => { + expect(result.current.hasAttemptedAccept).to.deep.equal(false) + expect(result.current.isSubmittingGuestbook).to.deep.equal(false) + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + expect(result.current.errorDownloadSignedUrlFile).to.deep.equal(null) + expect(typeof result.current.handleSubmit).to.deep.equal('function') + expect(typeof result.current.handleModalClose).to.deep.equal('function') + }) + }) + + it('does not submit when account fields are invalid', async () => { + const handleClose = cy.stub().as('handleClose') + const triggerDirectDownload = cy.stub().resolves(undefined) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose, + accessRepository, + triggerDirectDownload + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: true, + guestbook, + answers + }) + }) + + expect(result.current.hasAttemptedAccept).to.deep.equal(true) + expect(accessRepository.submitGuestbookForDatafileDownload).to.not.have.been.called + expect(accessRepository.submitGuestbookForDatafilesDownload).to.not.have.been.called + expect(triggerDirectDownload).to.not.have.been.called + expect(handleClose).to.not.have.been.called + }) + + it('submits for a single file id and triggers download', async () => { + const handleClose = cy.stub().as('handleClose') + const triggerDirectDownload = cy.stub().resolves(undefined) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose, + accessRepository, + triggerDirectDownload + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: false, + guestbook, + answers + }) + }) + + expect(accessRepository.submitGuestbookForDatafileDownload).to.have.been.calledWith(10, answers) + expect(accessRepository.submitGuestbookForDatafilesDownload).to.not.have.been.called + expect(handleClose).to.have.been.calledOnce + expect(triggerDirectDownload).to.have.been.calledOnceWith('signed-url-datafile') + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + }) + + it('submits for multiple file ids and triggers download', async () => { + const handleClose = cy.stub().as('handleClose') + const triggerDirectDownload = cy.stub().resolves(undefined) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileIds: [10, 11], + handleClose, + accessRepository, + triggerDirectDownload + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: false, + guestbook, + answers + }) + }) + + expect(accessRepository.submitGuestbookForDatafilesDownload).to.have.been.calledWith( + [10, 11], + answers + ) + expect(accessRepository.submitGuestbookForDatafileDownload).to.not.have.been.called + expect(handleClose).to.have.been.calledOnce + expect(triggerDirectDownload).to.have.been.calledOnceWith('signed-url-datafiles') + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + }) + + it('sets formatted message for WriteError', async () => { + accessRepository.submitGuestbookForDatafileDownload = cy.stub().rejects(new WriteError('')) + const handleClose = cy.stub().as('handleClose') + const triggerDirectDownload = cy.stub().resolves(undefined) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose, + accessRepository, + triggerDirectDownload + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: false, + guestbook, + answers + }) + }) + + expect(handleClose).to.not.have.been.called + expect(triggerDirectDownload).to.not.have.been.called + expect(result.current.isSubmittingGuestbook).to.deep.equal(false) + expect(result.current.errorSubmitGuestbook).to.deep.equal( + 'There was an error when writing the resource.' + ) + }) + + it('sets default message for non-WriteError exceptions', async () => { + accessRepository.submitGuestbookForDatafileDownload = cy.stub().rejects(new Error('unknown')) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose: cy.stub().as('handleClose'), + accessRepository, + triggerDirectDownload: cy.stub().resolves(undefined) + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: false, + guestbook, + answers + }) + }) + + expect(result.current.errorSubmitGuestbook).to.deep.equal( + 'Something went wrong submitting guestbook responses. Try again later.' + ) + expect(result.current.isSubmittingGuestbook).to.deep.equal(false) + }) + + it('sets download error when triggerDirectDownload fails', async () => { + const handleClose = cy.stub().as('handleClose') + const triggerDirectDownload = cy.stub().rejects(new Error('Download failed')) + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose, + accessRepository, + triggerDirectDownload + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: false, + guestbook, + answers + }) + }) + + await waitFor(() => { + expect(result.current.errorDownloadSignedUrlFile).to.deep.equal('Download failed') + }) + + expect(handleClose).to.have.been.calledOnce + expect(triggerDirectDownload).to.have.been.calledOnce + }) + + it('resets submission state on handleModalClose', async () => { + const handleClose = cy.stub().as('handleClose') + const { result } = renderHook(() => + useGuestbookAppliedSubmission({ + fileId: 10, + handleClose, + accessRepository, + triggerDirectDownload: cy.stub().resolves(undefined) + }) + ) + + await act(async () => { + await result.current.handleSubmit({ + hasAccountFieldErrors: true, + guestbook, + answers + }) + }) + expect(result.current.hasAttemptedAccept).to.deep.equal(true) + + act(() => { + result.current.handleModalClose() + }) + + await waitFor(() => { + expect(result.current.hasAttemptedAccept).to.deep.equal(false) + expect(result.current.errorSubmitGuestbook).to.deep.equal(null) + expect(result.current.errorDownloadSignedUrlFile).to.deep.equal(null) + }) + expect(handleClose).to.have.been.calledOnce + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithGuestbookModal.spec.tsx b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithGuestbookModal.spec.tsx new file mode 100644 index 000000000..89c7fbcd0 --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/guestbook/DownloadWithGuestbookModal.spec.tsx @@ -0,0 +1,386 @@ +import { DownloadWithGuestbookModal } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/DownloadWithGuestbookModal' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { DatasetLicense } from '@/dataset/domain/models/Dataset' + +const guestbook: Guestbook = { + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +const guestbookWithCustomQuestions: Guestbook = { + ...guestbook, + customQuestions: [ + { + question: 'Hidden question', + required: false, + displayOrder: 0, + type: 'text', + hidden: true + }, + { + question: 'Preferred format', + required: false, + displayOrder: 2, + type: 'options', + hidden: false, + optionValues: [ + { value: 'CSV', displayOrder: 2 }, + { value: 'JSON', displayOrder: 1 } + ] + }, + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'textarea', + hidden: false + } + ] +} + +const guestbookRepository: GuestbookRepository = {} as GuestbookRepository +const accessRepository: AccessRepository = {} as AccessRepository +const datasetLicense: DatasetLicense = { + name: 'CC0 1.0', + uri: 'https://creativecommons.org/publicdomain/zero/1.0/' +} + +describe('DownloadWithGuestbookModal', () => { + beforeEach(() => { + guestbookRepository.getGuestbook = cy.stub().resolves(guestbook) + accessRepository.submitGuestbookForDatafileDownload = cy + .stub() + .resolves('/api/v1/access/datafile/10?token=test') + accessRepository.submitGuestbookForDatafilesDownload = cy + .stub() + .resolves('/api/v1/access/datafiles/10,11?token=test') + }) + + it('renders modal title and actions', () => { + cy.customMount( + + ) + + cy.findByRole('dialog').should('exist') + cy.findByRole('button', { name: 'Accept' }).should('exist') + cy.findByRole('button', { name: 'Cancel' }).should('exist') + + cy.findByText('Dataset Terms').should('exist') + cy.findByText('License/Data Use Agreement').should('exist') + }) + + it('renders dataset terms and license when they are provided', () => { + cy.customMount( + + ) + + cy.findByText('CC0 1.0').should('exist') + }) + + it('renders custom dataset terms when custom terms are available', () => { + cy.customMount( + + ) + + cy.findByText('File page custom terms text').should('exist') + cy.findByText('File page confidentiality declaration').should('exist') + cy.findByRole('link', { name: 'Custom Dataset Terms' }).should( + 'have.attr', + 'href', + '/spa/datasets?persistentId=doi%3A10.5072%2FFK2%2FFILEPAGE&tab=terms&termsTab=guestbook' + ) + }) + + it('keeps accept disabled when no guestbook is loaded', () => { + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Accept' }).should('be.disabled') + }) + + it('calls handleClose when clicking cancel', () => { + const handleClose = cy.stub().as('handleClose') + + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Cancel' }).click() + cy.get('@handleClose').should('have.been.calledOnce') + }) + + it('submits filled form and accepts', () => { + const handleClose = cy.stub().as('handleClose') + + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click').as('anchorClick') + }) + + cy.customMount( + + ) + + cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Email/) + .clear() + .type('test.user@example.com') + cy.findByRole('button', { name: 'Accept' }).click() + + cy.wrap(accessRepository.submitGuestbookForDatafileDownload).should('have.been.calledOnce') + cy.get('@anchorClick').should('have.been.calledOnce') + cy.get('@handleClose').should('have.been.calledOnce') + cy.findByText('This field is required.').should('not.exist') + }) + + it('disables name and email fields for authenticated users', () => { + cy.mountAuthenticated( + + ) + + cy.findByLabelText(/^Name/).should('be.disabled') + cy.findByLabelText(/^Email/).should('be.disabled') + }) + + it('submits filled form and accepts for multiple files', () => { + const handleClose = cy.stub().as('handleClose') + + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click').as('anchorClick') + }) + + cy.customMount( + + ) + + cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Email/) + .clear() + .type('test.user@example.com') + cy.findByRole('button', { name: 'Accept' }).click() + + cy.wrap(accessRepository.submitGuestbookForDatafilesDownload).should('have.been.calledOnce') + cy.wrap(accessRepository.submitGuestbookForDatafilesDownload) + .its('firstCall.args.0') + .should('deep.equal', [10, 11]) + cy.wrap(accessRepository.submitGuestbookForDatafileDownload).should('not.have.been.called') + cy.get('@anchorClick').should('have.been.calledOnce') + cy.get('@handleClose').should('have.been.calledOnce') + }) + + it('submits filled form and accepts for many files', () => { + accessRepository.submitGuestbookForDatafilesDownload = cy + .stub() + .resolves('/api/v1/access/datafiles/10,11,12?token=test') + const handleClose = cy.stub().as('handleClose') + + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click').as('anchorClick') + }) + + cy.customMount( + + ) + + cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Email/) + .clear() + .type('test.user@example.com') + cy.findByRole('button', { name: 'Accept' }).click() + + cy.wrap(accessRepository.submitGuestbookForDatafilesDownload).should('have.been.calledOnce') + cy.wrap(accessRepository.submitGuestbookForDatafilesDownload) + .its('firstCall.args.0') + .should('deep.equal', [10, 11, 12]) + cy.wrap(accessRepository.submitGuestbookForDatafileDownload).should('not.have.been.called') + cy.get('@anchorClick').should('have.been.calledOnce') + cy.get('@handleClose').should('have.been.calledOnce') + }) + + it('shows required field validation after clicking accept', () => { + cy.customMount( + + ) + + cy.findByRole('button', { name: 'Accept' }).click() + + cy.findAllByText('This field is required.').should('have.length.at.least', 2) + }) + + it('renders visible custom questions and submits answers with generated field ids', () => { + guestbookRepository.getGuestbook = cy.stub().resolves(guestbookWithCustomQuestions) + const handleClose = cy.stub().as('handleClose') + + cy.window().then((window) => { + cy.stub(window.HTMLAnchorElement.prototype, 'click').as('anchorClick') + }) + + cy.customMount( + + ) + + cy.findByText('Hidden question').should('not.exist') + + cy.findByText('How will you use this data?') + .parents('div') + .first() + .find('textarea') + .type('For a replication package') + + cy.findByText('Preferred format').parents('div').first().find('button').click() + cy.findByText('JSON').should('exist') + cy.findByText('CSV').click() + + cy.findByLabelText(/^Name/).clear().type('Test User') + cy.findByLabelText(/^Email/) + .clear() + .type('test.user@example.com') + cy.findByRole('button', { name: 'Accept' }).click() + + cy.wrap(accessRepository.submitGuestbookForDatafileDownload).should('have.been.calledOnce') + cy.wrap(accessRepository.submitGuestbookForDatafileDownload) + .its('firstCall.args.1') + .should('deep.equal', [ + { id: 'name', value: 'Test User' }, + { id: 'email', value: 'test.user@example.com' }, + { id: 'custom-question-2-0', value: 'CSV' }, + { id: 'custom-question-1-1', value: 'For a replication package' } + ]) + cy.get('@anchorClick').should('have.been.calledOnce') + cy.get('@handleClose').should('have.been.calledOnce') + }) + + it('shows a dropdown box for additional questions of options type', () => { + guestbookRepository.getGuestbook = cy.stub().resolves(guestbookWithCustomQuestions) + + cy.customMount( + + ) + + cy.findByText('Preferred format').parents('div').first().find('button').should('exist') + cy.findByText('Preferred format').parents('div').first().find('button').click() + cy.findByText('JSON').should('exist') + cy.findByText('CSV').should('exist') + }) + + it('shows error alert when get guestbook request fails', () => { + guestbookRepository.getGuestbook = cy.stub().rejects(new Error('some guestbook error')) + + cy.customMount( + + ) + + cy.findByText(/Something went wrong getting the guestbook. Try again later./).should('exist') + cy.findByRole('button', { name: 'Accept' }).should('be.disabled') + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/guestbook/useGetGuestbookById.spec.tsx b/tests/component/sections/dataset/dataset-files/guestbook/useGetGuestbookById.spec.tsx new file mode 100644 index 000000000..b3912d9be --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/guestbook/useGetGuestbookById.spec.tsx @@ -0,0 +1,84 @@ +import { ReadError } from '@iqss/dataverse-client-javascript' +import { renderHook, waitFor } from '@testing-library/react' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { useGetGuestbookById } from '@/sections/dataset/dataset-guestbook/useGetGuestbookById' + +const guestbookRepository: GuestbookRepository = {} as GuestbookRepository +const guestbook: Guestbook = { + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +describe('useGetGuestbookById', () => { + it('should return guestbook', async () => { + guestbookRepository.getGuestbook = cy.stub().resolves(guestbook) + + const { result } = renderHook(() => + useGetGuestbookById({ guestbookRepository, guestbookId: guestbook.id }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbook).to.deep.equal(false) + expect(result.current.errorGetGuestbook).to.deep.equal(null) + expect(result.current.guestbook).to.deep.equal(guestbook) + }) + + cy.wrap(guestbookRepository.getGuestbook).should('have.been.calledOnceWith', guestbook.id) + }) + + it('should not fetch guestbook when guestbookId is undefined', async () => { + guestbookRepository.getGuestbook = cy.stub().resolves(guestbook) + + const { result } = renderHook(() => + useGetGuestbookById({ guestbookRepository, guestbookId: undefined }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbook).to.deep.equal(false) + expect(result.current.errorGetGuestbook).to.deep.equal(null) + }) + + cy.wrap(guestbookRepository.getGuestbook).should('not.have.been.called') + }) + + describe('Error handling', () => { + it('should return correct error message when it is a ReadError instance from js-dataverse', async () => { + guestbookRepository.getGuestbook = cy.stub().rejects(new ReadError('Error message')) + + const { result } = renderHook(() => + useGetGuestbookById({ guestbookRepository, guestbookId: guestbook.id }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbook).to.deep.equal(false) + expect(result.current.guestbook).to.deep.equal(undefined) + expect(result.current.errorGetGuestbook).to.deep.equal('Error message') + }) + }) + + it('should return correct default error message when it is not a ReadError instance from js-dataverse', async () => { + guestbookRepository.getGuestbook = cy.stub().rejects('Error message') + + const { result } = renderHook(() => + useGetGuestbookById({ guestbookRepository, guestbookId: guestbook.id }) + ) + + await waitFor(() => { + expect(result.current.isLoadingGuestbook).to.deep.equal(false) + expect(result.current.guestbook).to.deep.equal(undefined) + expect(result.current.errorGetGuestbook).to.deep.equal( + 'Something went wrong getting the guestbook. Try again later.' + ) + }) + }) + }) +}) diff --git a/tests/component/sections/dataset/dataset-terms/DatasetTerms.spec.tsx b/tests/component/sections/dataset/dataset-terms/DatasetTerms.spec.tsx index 05bff3b11..5ef5ffea1 100644 --- a/tests/component/sections/dataset/dataset-terms/DatasetTerms.spec.tsx +++ b/tests/component/sections/dataset/dataset-terms/DatasetTerms.spec.tsx @@ -8,6 +8,10 @@ import { TermsOfUseMother, TermsOfAccessMother } from '../../../dataset/domain/models/TermsOfUseMother' +import { DatasetContext } from '@/sections/dataset/DatasetContext' +import { Dataset as DatasetModel } from '@/dataset/domain/models/Dataset' +import { ReactNode } from 'react' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' const datasetPersistentId = 'test-dataset-persistent-id' const datasetVersion = DatasetMother.create().version @@ -47,8 +51,15 @@ const accessNotAllowedTermsOfUse = TermsOfUseMother.create({ const termsOfUseWithUndefinedValue = TermsOfUseMother.create({ termsOfAccess: TermsOfAccessMother.create({ dataAccessPlace: undefined }) }) +const guestbookRepository: GuestbookRepository = {} as GuestbookRepository describe('DatasetTerms', () => { + const withDatasetContext = (component: ReactNode, dataset?: DatasetModel) => ( + {} }}> + {component} + + ) + beforeEach(() => { fileRepository.getFilesCountInfoByDatasetPersistentId = cy .stub() @@ -71,6 +82,102 @@ describe('DatasetTerms', () => { cy.findByText('Restricted Files + Terms of Access').should('exist') }) + it('shows no guestbook assigned message after expanding the guestbook accordion', () => { + cy.customMount( + withDatasetContext( + , + DatasetMother.create({ guestbookId: undefined }) + ) + ) + + cy.findByRole('button', { name: 'Guestbook' }).should('have.attr', 'aria-expanded', 'false') + cy.findByRole('button', { name: 'Guestbook' }).click() + cy.findByTestId('dataset-guestbook-empty-message').should( + 'contain.text', + 'No guestbook is assigned to this dataset so users will not be prompted to provide any information when downloading files. To learn more about guestbooks, visit the Dataset Guestbook section of the User Guide.' + ) + }) + + it('shows guestbook details after expanding the guestbook accordion', () => { + const guestbookId = 10 + const guestbookName = 'Guestbook Test' + guestbookRepository.getGuestbook = cy.stub().resolves({ + id: guestbookId, + name: guestbookName, + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + + cy.customMount( + withDatasetContext( + , + DatasetMother.create({ guestbookId }) + ) + ) + + cy.findByRole('button', { name: 'Guestbook' }).should('have.attr', 'aria-expanded', 'false') + cy.findByRole('button', { name: 'Guestbook' }).click() + + cy.findByTestId('dataset-guestbook-description').should( + 'contain.text', + 'The following guestbook will prompt a user to provide additional information when downloading a file.' + ) + cy.findByTestId('dataset-guestbook-name').should('contain.text', guestbookName) + }) + + it('opens guestbook accordion by default when termsTab=guestbook is present in query params', () => { + const guestbookId = 10 + guestbookRepository.getGuestbook = cy.stub().resolves({ + id: guestbookId, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }) + + cy.customMount( + withDatasetContext( + , + DatasetMother.create({ guestbookId }) + ), + ['/datasets?tab=terms&termsTab=guestbook'] + ) + + cy.findByRole('button', { name: 'Guestbook' }).should('have.attr', 'aria-expanded', 'true') + cy.findByTestId('dataset-guestbook-name').should('contain.text', 'Guestbook Test') + }) + it('check that the terms of use sections are rendered even without edit permissions', () => { cy.customMount( { it('should return dataset version differences correctly', async () => { - datasetRepository.getVersionDiff = cy.stub().resolves(datasetVersionDiffMock) + datasetRepository.getVersionDiff = cy.stub().resolves(datasetVersionDiff) const { result } = renderHook(() => useGetDatasetVersionDiff({ datasetRepository, @@ -32,7 +26,7 @@ describe('useGetDatasetVersionDiff', () => { return expect(result.current.differences).to.deep.equal(undefined) }) - void waitFor(() => { + await waitFor(() => { expect(result.current.isLoading).to.deep.equal(false) return expect(result.current.differences).to.deep.equal(datasetVersionDiff) }) @@ -56,8 +50,10 @@ describe('useGetDatasetVersionDiff', () => { return expect(result.current.differences).to.deep.equal(undefined) }) - expect(result.current.isLoading).to.deep.equal(false) - expect(result.current.error).to.deep.equal(error.message) + await waitFor(() => { + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal(error.message) + }) }) it('should return a generic error message for non-Error exceptions', async () => { @@ -78,7 +74,7 @@ describe('useGetDatasetVersionDiff', () => { return expect(result.current.differences).to.deep.equal(undefined) }) - await act(() => { + await waitFor(() => { expect(result.current.isLoading).to.deep.equal(false) return expect(result.current.error).to.deep.equal( 'Something went wrong getting the information from the dataset version differences. Try again later.' diff --git a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx index 938df0b50..919e7dfb5 100644 --- a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx @@ -11,6 +11,8 @@ import { } from '@tests/component/dataset/domain/models/TermsOfUseMother' import { Dataset } from '@/dataset/domain/models/Dataset' import { License } from '@/licenses/domain/models/License' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' const licenseRepository: LicenseRepository = {} as LicenseRepository const datasetRepository: DatasetRepository = {} as DatasetRepository @@ -46,6 +48,41 @@ const mockLicenses: License[] = [ } ] +const mockGuestbooks: Guestbook[] = [ + { + id: 1, + name: 'Data Request Guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2025-03-11T00:00:00Z', + dataverseId: 10 + }, + { + id: 2, + name: 'Secondary Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2025-03-11T00:00:00Z', + dataverseId: 10 + } +] + describe('EditDatasetTerms', () => { const withProviders = (component: ReactNode, dataset: Dataset) => { datasetRepository.getByPersistentId = cy.stub().resolves(dataset) @@ -64,6 +101,32 @@ describe('EditDatasetTerms', () => { licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) }) + describe('EditDatasetTermsHelper', () => { + it('maps guestbook tab query param to guestbook tab key', () => { + const searchParams = new URLSearchParams({ + [EditDatasetTermsHelper.EDIT_DATASET_TERMS_TAB_QUERY_KEY]: 'guestbook' + }) + + expect(EditDatasetTermsHelper.defineSelectedTabKey(searchParams)).to.equal( + EditDatasetTermsHelper.EDIT_DATASET_TERMS_TABS_KEYS.guestbook + ) + }) + + it('falls back to dataset terms tab key when tab query param is missing or invalid', () => { + const missingTabSearchParams = new URLSearchParams() + const invalidTabSearchParams = new URLSearchParams({ + [EditDatasetTermsHelper.EDIT_DATASET_TERMS_TAB_QUERY_KEY]: 'invalid-tab' + }) + + expect(EditDatasetTermsHelper.defineSelectedTabKey(missingTabSearchParams)).to.equal( + EditDatasetTermsHelper.EDIT_DATASET_TERMS_TABS_KEYS.datasetTerms + ) + expect(EditDatasetTermsHelper.defineSelectedTabKey(invalidTabSearchParams)).to.equal( + EditDatasetTermsHelper.EDIT_DATASET_TERMS_TABS_KEYS.datasetTerms + ) + }) + }) + describe('Tab Navigation', () => { it('renders all three tabs', () => { const dataset = DatasetMother.create({ @@ -84,7 +147,7 @@ describe('EditDatasetTerms', () => { cy.findByRole('tab', { name: 'Dataset Terms' }).should('exist') cy.findByRole('tab', { name: 'Restricted Files + Terms of Access' }).should('exist') - cy.findByRole('tab', { name: 'GuestBook' }).should('exist') + cy.findByRole('tab', { name: 'Guestbook' }).should('exist') }) it('switches between tabs correctly', () => { @@ -115,7 +178,7 @@ describe('EditDatasetTerms', () => { cy.findByLabelText('Enable access request').should('exist') - cy.findByRole('tab', { name: 'GuestBook' }).should('not.be.selected') + cy.findByRole('tab', { name: 'Guestbook' }).should('not.be.selected') }) it('starts with the correct default tab', () => { @@ -361,6 +424,202 @@ describe('EditDatasetTerms', () => { }) }) + describe('Guestbook Tab Integration', () => { + it('displays available guestbooks and keeps Save Changes disabled for current guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ + license: mockLicenses[0], + guestbookId: mockGuestbooks[0].id + }) + + cy.customMount( + withProviders( + , + dataset + ) + ) + + cy.findByRole('tab', { name: 'Guestbook' }).should('have.attr', 'aria-selected', 'true') + cy.findByLabelText('Data Request Guestbook').should('be.checked') + cy.findByLabelText('Secondary Guestbook').should('not.be.checked') + cy.findAllByRole('button', { name: 'Preview Guestbook' }).should('have.length', 2) + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + }) + + it('enables Save Changes when selecting a different guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ + license: mockLicenses[0], + guestbookId: mockGuestbooks[0].id + }) + + cy.customMount( + withProviders( + , + dataset + ) + ) + + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + }) + + it('opens guestbook preview modal from guestbook tab', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ + license: mockLicenses[0], + guestbookId: mockGuestbooks[1].id + }) + + cy.customMount( + withProviders( + , + dataset + ) + ) + + cy.findAllByRole('button', { name: 'Preview Guestbook' }).should('have.length', 2) + cy.findAllByRole('button', { name: 'Preview Guestbook' }).eq(1).click() + + cy.findByRole('dialog') + .should('be.visible') + .within(() => { + cy.findByText('Secondary Guestbook').should('exist') + cy.findByText(/How will you use this data?/).should('exist') + cy.findByText(/Email/).should('exist') + }) + }) + }) + + describe('Tab State Guard Branches', () => { + const withProvidersOptionalDataset = (component: ReactNode, dataset: Dataset | undefined) => { + datasetRepository.getByPersistentId = cy.stub().resolves(dataset) + datasetRepository.getByPrivateUrlToken = cy.stub().resolves(dataset) + return ( + + {component} + + ) + } + + beforeEach(() => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + }) + + it('renders not found page when dataset does not exist', () => { + cy.customMount( + withProvidersOptionalDataset( + , + undefined + ) + ) + + cy.findByText('404').should('exist') + }) + + it('shows unsaved changes modal when restricted files tab form is dirty and switching tabs', () => { + const dataset = DatasetMother.create() + cy.customMount( + withProvidersOptionalDataset( + , + dataset + ) + ) + + cy.findByLabelText('Enable access request').uncheck() + cy.findByLabelText(/Terms of Access for Restricted Files/i) + .clear() + .type('Need contact approval') + + cy.findByRole('tab', { name: 'Dataset Terms' }).click() + cy.findByText('Unsaved Changes').should('exist') + }) + + it('switches from guestbook tab without unsaved modal when guestbook form is not dirty', () => { + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + cy.customMount( + withProvidersOptionalDataset( + , + dataset + ) + ) + + cy.findByRole('tab', { name: 'Dataset Terms' }).click() + cy.findByText('Unsaved Changes').should('not.exist') + cy.findByRole('tab', { name: 'Dataset Terms' }).should('have.attr', 'aria-selected', 'true') + }) + + it('uses default dirty-state branch when active tab key is unknown', () => { + const dataset = DatasetMother.create() + cy.customMount( + withProvidersOptionalDataset( + , + dataset + ) + ) + + cy.findByRole('tab', { name: 'Restricted Files + Terms of Access' }).click() + cy.findByText('Unsaved Changes').should('not.exist') + cy.findByRole('tab', { name: 'Restricted Files + Terms of Access' }).should( + 'have.attr', + 'aria-selected', + 'true' + ) + }) + + it('does not trigger tab switch flow when selecting the current active tab', () => { + const dataset = DatasetMother.create() + cy.customMount( + withProvidersOptionalDataset( + , + dataset + ) + ) + + cy.get('select').select('CC0 1.0') + cy.findByRole('tab', { name: 'Dataset Terms' }).click() + cy.findByText('Unsaved Changes').should('not.exist') + cy.findByRole('tab', { name: 'Dataset Terms' }).should('have.attr', 'aria-selected', 'true') + }) + }) + describe('Breadcrumbs', () => { it('displays correct breadcrumbs', () => { const dataset = DatasetMother.create({ diff --git a/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx new file mode 100644 index 000000000..90294a271 --- /dev/null +++ b/tests/component/sections/edit-dataset-terms/EditGuestbook.spec.tsx @@ -0,0 +1,400 @@ +import { ReactNode, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { UpwardHierarchyNodeMother } from '@tests/component/shared/hierarchy/domain/models/UpwardHierarchyNodeMother' +import { EditGuestbook } from '@/sections/edit-dataset-terms/edit-guestbook/EditGuestbook' +import { DatasetProvider } from '@/sections/dataset/DatasetProvider' +import { DatasetContext } from '@/sections/dataset/DatasetContext' +import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { + DatasetMother, + DatasetVersionMother +} from '@tests/component/dataset/domain/models/DatasetMother' +import { Dataset } from '@/dataset/domain/models/Dataset' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { + assignDatasetGuestbook, + getGuestbooksByCollectionId +} from '@iqss/dataverse-client-javascript' + +const LocationDisplay = () => { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +const datasetRepository: DatasetRepository = {} as DatasetRepository + +const mockGuestbooks: Guestbook[] = [ + { + id: 1, + name: 'Data Request Guestbook', + enabled: true, + emailRequired: true, + nameRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + }, + { + id: 2, + name: 'Secondary Guestbook', + enabled: true, + emailRequired: true, + nameRequired: false, + institutionRequired: false, + positionRequired: false, + customQuestions: [ + { + question: 'How will you use this data?', + required: true, + displayOrder: 1, + type: 'text', + hidden: false + } + ], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 + } +] + +describe('EditGuestbook', () => { + const withProviders = (component: ReactNode, dataset: Dataset) => { + datasetRepository.getByPersistentId = cy.stub().resolves(dataset) + datasetRepository.getByPrivateUrlToken = cy.stub().resolves(dataset) + + return ( + + {component} + + ) + } + + const withDatasetContext = (component: ReactNode, dataset: Dataset | undefined) => ( + {} + }}> + {component} + + ) + + it('renders guestbook options and keeps Save Changes disabled for current guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Data Request Guestbook').should('be.checked') + cy.findByLabelText('Secondary Guestbook').should('not.be.checked') + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + }) + + it('enables Save Changes when selecting a different guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + }) + + it('keeps Save Changes disabled when dataset has no assigned guestbook and none is selected', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: undefined }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Data Request Guestbook').should('not.be.checked') + cy.findByLabelText('Secondary Guestbook').should('not.be.checked') + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + }) + + it('falls back to no preselection when dataset guestbook is not in fetched list', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: 9999 }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Data Request Guestbook').should('not.be.checked') + cy.findByLabelText('Secondary Guestbook').should('not.be.checked') + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + }) + + it('renders the empty state message with the collection name', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves([]) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Data Request Guestbook').should('not.exist') + cy.findByLabelText('Secondary Guestbook').should('not.exist') + cy.findByRole('link', { name: 'Dataset Guestbook' }).should( + 'have.attr', + 'href', + 'https://guides.dataverse.org/en/6.9/user/dataverse-management.html#dataset-guestbooks' + ) + cy.findByText(/There are no guestbooks enabled in Root\./).should('exist') + cy.findByText(/To create a guestbook, return to Root,/).should('exist') + cy.findByText(/select the "Dataset Guestbooks" option\./).should('exist') + cy.findByRole('button', { name: 'Save Changes' }).should('be.disabled') + }) + + it('opens preview modal when clicking Preview Guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findAllByRole('button', { name: 'Preview Guestbook' }).should('have.length', 2) + cy.findAllByRole('button', { name: 'Preview Guestbook' }).eq(1).click() + + cy.findByRole('dialog') + .should('be.visible') + .within(() => { + cy.findByText('Secondary Guestbook').should('exist') + cy.findByText(/How will you use this data\?/).should('exist') + cy.findByText('Preview Guestbook').should('exist') + cy.findByText('Secondary Guestbook').should('exist') + cy.findByText(/Account Information/).should('exist') + cy.findByText(/Custom Questions/).should('exist') + cy.findByText(/Email \(Required\)/).should('exist') + cy.findByText(/Institution \(Optional\)/).should('exist') + cy.findByText(/Name \(Optional\)/).should('exist') + cy.findByText(/Position \(Optional\)/).should('exist') + }) + }) + + it('closes preview modal when clicking Close', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findAllByRole('button', { name: 'Preview Guestbook' }).eq(0).click() + cy.findByRole('dialog').should('be.visible') + cy.findByText('Close').click() + cy.findByRole('dialog').should('not.exist') + }) + + it('calls onPreview callback when provided', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const onPreview = cy.stub().as('onPreview') + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findAllByRole('button', { name: 'Preview Guestbook' }).eq(0).click() + cy.get('@onPreview').should('have.been.calledOnce') + cy.findByRole('dialog').should('not.exist') + }) + + it('submits selected guestbook', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const assignDatasetGuestbookExecute = cy.stub(assignDatasetGuestbook, 'execute') + assignDatasetGuestbookExecute.resolves(undefined) + assignDatasetGuestbookExecute.as('assignDatasetGuestbookExecute') + const dataset = DatasetMother.create({ id: 999, guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).click() + + cy.get('@assignDatasetGuestbookExecute').should( + 'have.been.calledOnceWith', + 999, + mockGuestbooks[1].id + ) + }) + + it('navigates with DRAFT version query param after successful submit for draft datasets', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + cy.stub(assignDatasetGuestbook, 'execute').resolves(undefined) + const draftDataset = DatasetMother.create({ + id: 999, + persistentId: 'doi:10.5072/FK2/DRAFTPID', + guestbookId: mockGuestbooks[0].id, + version: DatasetVersionMother.createDraft() + }) + + cy.customMount( + withProviders( + <> + + + >, + draftDataset + ) + ) + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).click() + cy.findByTestId('location-display').should( + 'have.text', + '/datasets?persistentId=doi%3A10.5072%2FFK2%2FDRAFTPID&version=DRAFT' + ) + }) + + it('navigates with numeric version query param after successful submit for non-draft datasets', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + cy.stub(assignDatasetGuestbook, 'execute').resolves(undefined) + const releasedDataset = DatasetMother.create({ + id: 999, + persistentId: 'doi:10.5072/FK2/RELEASEDPID', + guestbookId: mockGuestbooks[0].id, + version: DatasetVersionMother.createReleased() + }) + + cy.customMount( + withProviders( + <> + + + >, + releasedDataset + ) + ) + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).click() + cy.findByTestId('location-display').should( + 'have.text', + '/datasets?persistentId=doi%3A10.5072%2FFK2%2FRELEASEDPID&version=1.0' + ) + }) + + it('keeps selected guestbook when dataset has no assigned guestbook and selected id still exists after guestbooks refresh', () => { + const collectionA = 'collection-a' + const collectionB = 'collection-b' + const guestbooksForA = mockGuestbooks + const guestbooksForB = [mockGuestbooks[1]] + + cy.stub(getGuestbooksByCollectionId, 'execute').callsFake((collectionIdOrAlias) => { + if (collectionIdOrAlias === collectionA) return Promise.resolve(guestbooksForA) + return Promise.resolve(guestbooksForB) + }) + + const createDataset = (collectionId: string) => + DatasetMother.create({ + guestbookId: undefined, + hierarchy: UpwardHierarchyNodeMother.createDataset({ + parent: UpwardHierarchyNodeMother.createCollection({ id: collectionId, name: 'Root' }) + }) + }) + + const Harness = () => { + const [dataset, setDataset] = useState(createDataset(collectionA)) + return ( + <> + {withDatasetContext(, dataset)} + setDataset(createDataset(collectionB))} type="button"> + Switch Collection + + > + ) + } + + cy.customMount() + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + cy.findByRole('button', { name: 'Switch Collection' }).click() + cy.findByLabelText('Secondary Guestbook').should('be.checked') + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + }) + + it('keeps current selected guestbook when dataset has assigned guestbook but user selected another valid guestbook', () => { + const collectionA = 'collection-a' + const collectionB = 'collection-b' + const guestbooksForA = mockGuestbooks + const guestbooksForB = [mockGuestbooks[0], mockGuestbooks[1]] + + cy.stub(getGuestbooksByCollectionId, 'execute').callsFake((collectionIdOrAlias) => { + if (collectionIdOrAlias === collectionA) return Promise.resolve(guestbooksForA) + return Promise.resolve(guestbooksForB) + }) + + const createDataset = (collectionId: string) => + DatasetMother.create({ + guestbookId: mockGuestbooks[0].id, + hierarchy: UpwardHierarchyNodeMother.createDataset({ + parent: UpwardHierarchyNodeMother.createCollection({ id: collectionId, name: 'Root' }) + }) + }) + + const Harness = () => { + const [dataset, setDataset] = useState(createDataset(collectionA)) + return ( + <> + {withDatasetContext(, dataset)} + setDataset(createDataset(collectionB))} type="button"> + Switch Collection + + > + ) + } + + cy.customMount() + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Switch Collection' }).click() + cy.findByLabelText('Secondary Guestbook').should('be.checked') + cy.findByLabelText('Data Request Guestbook').should('not.be.checked') + }) + + it('does not submit when selectedGuestbookId is undefined', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const assignDatasetGuestbookExecute = cy + .stub(assignDatasetGuestbook, 'execute') + .as('assignDatasetGuestbookExecute') + const dataset = DatasetMother.create({ id: 999, guestbookId: undefined }) + + cy.customMount(withProviders(, dataset)) + + cy.get('form').submit() + cy.get('@assignDatasetGuestbookExecute').should('not.have.been.called') + }) + + it('does not submit when dataset is undefined', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + const assignDatasetGuestbookExecute = cy + .stub(assignDatasetGuestbook, 'execute') + .as('assignDatasetGuestbookExecute') + + cy.customMount(withDatasetContext(, undefined)) + + cy.get('form').submit() + cy.get('@assignDatasetGuestbookExecute').should('not.have.been.called') + }) + + it('shows assign guestbook error alert when save fails', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').resolves(mockGuestbooks) + cy.stub(assignDatasetGuestbook, 'execute').rejects(new Error('unexpected')) + const dataset = DatasetMother.create({ id: 999, guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByLabelText('Secondary Guestbook').click() + cy.findByRole('button', { name: 'Save Changes' }).click() + cy.findByText( + /An error occurred while updating the dataset guestbook. Please try again./ + ).should('exist') + }) + + it('shows an error alert when loading guestbooks fails', () => { + cy.stub(getGuestbooksByCollectionId, 'execute').rejects(new Error('network error')) + const dataset = DatasetMother.create({ guestbookId: mockGuestbooks[0].id }) + + cy.customMount(withProviders(, dataset)) + + cy.findByText( + /Something went wrong getting guestbooks by collection id. Try again later./ + ).should('exist') + }) +}) diff --git a/tests/component/sections/edit-dataset-terms/EditLicenseAndTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditLicenseAndTerms.spec.tsx index 7a0dbf0b4..3c776f751 100644 --- a/tests/component/sections/edit-dataset-terms/EditLicenseAndTerms.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditLicenseAndTerms.spec.tsx @@ -1,9 +1,13 @@ import { ReactNode } from 'react' +import { useLocation } from 'react-router-dom' import { EditLicenseAndTerms } from '@/sections/edit-dataset-terms/edit-license-and-terms/EditLicenseAndTerms' import { DatasetProvider } from '@/sections/dataset/DatasetProvider' import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { DatasetMother } from '@tests/component/dataset/domain/models/DatasetMother' +import { + DatasetMother, + DatasetVersionMother +} from '@tests/component/dataset/domain/models/DatasetMother' import { TermsOfUseMother } from '@tests/component/dataset/domain/models/TermsOfUseMother' import { Dataset } from '@/dataset/domain/models/Dataset' import { License } from '@/licenses/domain/models/License' @@ -52,6 +56,11 @@ const mockDatasetWithLicense = DatasetMother.create({ license: mockLicenses[0] }) +const LocationDisplay = () => { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + describe('EditLicenseAndTerms', () => { const withProviders = (component: ReactNode, dataset: Dataset) => { datasetRepository.getByPersistentId = cy.stub().resolves(dataset) @@ -66,6 +75,19 @@ describe('EditLicenseAndTerms', () => { ) } + const withLoadingDataset = (component: ReactNode) => { + datasetRepository.getByPersistentId = cy.stub().returns(new Promise(() => {})) + datasetRepository.getByPrivateUrlToken = cy.stub().returns(new Promise(() => {})) + + return ( + + {component} + + ) + } + beforeEach(() => { licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) }) @@ -180,6 +202,71 @@ describe('EditLicenseAndTerms', () => { cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') }) + + it('does not submit update when dataset is not loaded', () => { + datasetRepository.updateDatasetLicense = cy.stub().as('updateDatasetLicense') + + cy.customMount( + withLoadingDataset( + + ) + ) + + cy.get('select').select('CC0 1.0') + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + cy.findByRole('button', { name: 'Save Changes' }).click() + cy.get('@updateDatasetLicense').should('not.have.been.called') + }) + }) + + describe('Navigation', () => { + it('navigates to dataset view using DRAFT version when cancel is clicked on a draft dataset', () => { + const draftDataset = DatasetMother.create({ + persistentId: 'doi:10.5072/FK2/DRAFTPID', + version: DatasetVersionMother.createDraft() + }) + + cy.customMount( + withProviders( + <> + + + >, + draftDataset + ) + ) + + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByTestId('location-display').should( + 'have.text', + '/datasets?persistentId=doi%3A10.5072%2FFK2%2FDRAFTPID&version=DRAFT' + ) + }) + + it('does not navigate when cancel is clicked before dataset is loaded', () => { + cy.customMount( + withLoadingDataset( + <> + + + > + ), + ['/edit-terms'] + ) + + cy.findByTestId('location-display').should('contain', '/edit-terms') + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByTestId('location-display').should('contain', '/edit-terms') + }) }) describe('Loading States', () => { diff --git a/tests/component/sections/edit-dataset-terms/EditTermsOfAccess.spec.tsx b/tests/component/sections/edit-dataset-terms/EditTermsOfAccess.spec.tsx index 7e04a8927..eb1a023e8 100644 --- a/tests/component/sections/edit-dataset-terms/EditTermsOfAccess.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditTermsOfAccess.spec.tsx @@ -2,12 +2,21 @@ import { ReactNode } from 'react' import { EditTermsOfAccess } from '@/sections/edit-dataset-terms/edit-terms-of-access/EditTermsOfAccess' import { DatasetProvider } from '@/sections/dataset/DatasetProvider' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { DatasetMother } from '@tests/component/dataset/domain/models/DatasetMother' +import { + DatasetMother, + DatasetVersionMother +} from '@tests/component/dataset/domain/models/DatasetMother' import { TermsOfAccessMother, TermsOfUseMother } from '@tests/component/dataset/domain/models/TermsOfUseMother' import { Dataset } from '@/dataset/domain/models/Dataset' +import { useLocation } from 'react-router-dom' + +const LocationDisplay = () => { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} const datasetRepository: DatasetRepository = {} as DatasetRepository @@ -41,6 +50,19 @@ describe('EditTermsOfAccess', () => { ) } + const withLoadingDataset = (component: ReactNode) => { + datasetRepository.getByPersistentId = cy.stub().returns(new Promise(() => {})) + datasetRepository.getByPrivateUrlToken = cy.stub().returns(new Promise(() => {})) + + return ( + + {component} + + ) + } + describe('Request Access Section', () => { it('renders the request access checkbox', () => { cy.customMount( @@ -137,6 +159,17 @@ describe('EditTermsOfAccess', () => { cy.findByLabelText('Terms of Access for Restricted Files').clear().type('New terms') cy.findByRole('button', { name: 'Save Changes' }).click() }) + + it('shows "Saving" while terms are being submitted and disables the button', () => { + datasetRepository.updateTermsOfAccess = cy.stub().returns(new Promise(() => {})) + + cy.customMount( + withProviders(, mockDataset) + ) + + cy.findByRole('button', { name: 'Save Changes' }).click() + cy.findByRole('button', { name: 'Saving' }).should('exist').and('be.disabled') + }) }) it('handles empty initial terms of access', () => { @@ -148,6 +181,90 @@ describe('EditTermsOfAccess', () => { cy.findByLabelText('Terms of Access for Restricted Files').should('exist') }) + it('treats undefined fileAccessRequest as enabled by default', () => { + const datasetWithUndefinedRequest = DatasetMother.create({ + termsOfUse: TermsOfUseMother.withoutCustomTerms({ + termsOfAccess: TermsOfAccessMother.create({ + fileAccessRequest: undefined as unknown as boolean, + termsOfAccessForRestrictedFiles: undefined + }) + }) + }) + + cy.customMount( + withProviders( + , + datasetWithUndefinedRequest + ) + ) + + cy.findByRole('button', { name: 'Save Changes' }).should('be.enabled') + }) + + describe('Cancel navigation', () => { + it('returns early and does not navigate when dataset is not loaded yet', () => { + cy.customMount( + withLoadingDataset( + <> + + + > + ), + ['/edit-terms'] + ) + + cy.findByTestId('location-display').should('contain', '/edit-terms') + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByTestId('location-display').should('contain', '/edit-terms') + }) + + it('navigates to dataset page with DRAFT version query param when dataset is draft', () => { + const draftDataset = DatasetMother.create({ + persistentId: 'doi:10.5072/FK2/DRAFTPID', + version: DatasetVersionMother.createDraft() + }) + + cy.customMount( + withProviders( + <> + + + >, + draftDataset + ) + ) + + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByTestId('location-display').should( + 'have.text', + '/datasets?persistentId=doi%3A10.5072%2FFK2%2FDRAFTPID&version=DRAFT' + ) + }) + + it('navigates to dataset page with numeric version query param when dataset is released', () => { + const releasedDataset = DatasetMother.create({ + persistentId: 'doi:10.5072/FK2/RELEASEDPID', + version: DatasetVersionMother.createReleased() + }) + + cy.customMount( + withProviders( + <> + + + >, + releasedDataset + ) + ) + + cy.findByRole('button', { name: 'Cancel' }).click() + cy.findByTestId('location-display').should( + 'have.text', + '/datasets?persistentId=doi%3A10.5072%2FFK2%2FRELEASEDPID&version=1.0' + ) + }) + }) + describe('Toast Notifications', () => { it('displays success toast when terms of access are updated successfully', () => { const termsOfAccess = TermsOfAccessMother.create({ diff --git a/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx new file mode 100644 index 000000000..11832ad9e --- /dev/null +++ b/tests/component/sections/edit-dataset-terms/useAssignDatasetGuestbook.spec.tsx @@ -0,0 +1,87 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { assignDatasetGuestbook, WriteError } from '@iqss/dataverse-client-javascript' +import { useAssignDatasetGuestbook } from '@/sections/edit-dataset-terms/edit-guestbook/useAssignDatasetGuestbook' + +describe('useAssignDatasetGuestbook', () => { + let onSuccessfulAssignDatasetGuestbook: () => void + + beforeEach(() => { + onSuccessfulAssignDatasetGuestbook = cy.stub().as('onSuccessfulAssignDatasetGuestbook') + }) + + it('should initialize with default state', async () => { + const { result } = renderHook(() => + useAssignDatasetGuestbook({ + onSuccessfulAssignDatasetGuestbook + }) + ) + + await waitFor(() => { + expect(result.current.isLoadingAssignDatasetGuestbook).to.deep.equal(false) + expect(result.current.errorAssignDatasetGuestbook).to.deep.equal(null) + expect(typeof result.current.handleAssignDatasetGuestbook).to.deep.equal('function') + }) + }) + + it('should assign guestbook successfully', async () => { + const executeStub = cy.stub(assignDatasetGuestbook, 'execute').resolves(undefined) + + const { result } = renderHook(() => + useAssignDatasetGuestbook({ + onSuccessfulAssignDatasetGuestbook + }) + ) + + await act(async () => { + await result.current.handleAssignDatasetGuestbook(123, 5) + }) + + expect(executeStub).to.have.been.calledWith(123, 5) + expect(onSuccessfulAssignDatasetGuestbook).to.have.been.calledOnce + expect(result.current.isLoadingAssignDatasetGuestbook).to.deep.equal(false) + expect(result.current.errorAssignDatasetGuestbook).to.deep.equal(null) + }) + + it('should handle WriteError', async () => { + const writeError = new WriteError() + const executeStub = cy.stub(assignDatasetGuestbook, 'execute').rejects(writeError) + + const { result } = renderHook(() => + useAssignDatasetGuestbook({ + onSuccessfulAssignDatasetGuestbook + }) + ) + + await act(async () => { + await result.current.handleAssignDatasetGuestbook(123, 5) + }) + + expect(executeStub).to.have.been.calledWith(123, 5) + expect(onSuccessfulAssignDatasetGuestbook).to.not.have.been.called + expect(result.current.isLoadingAssignDatasetGuestbook).to.deep.equal(false) + expect(result.current.errorAssignDatasetGuestbook).to.not.deep.equal(null) + }) + + it('should handle unknown errors and set default message', async () => { + const executeStub = cy + .stub(assignDatasetGuestbook, 'execute') + .rejects(new Error('Unknown error')) + + const { result } = renderHook(() => + useAssignDatasetGuestbook({ + onSuccessfulAssignDatasetGuestbook + }) + ) + + await act(async () => { + await result.current.handleAssignDatasetGuestbook(123, 5) + }) + + expect(executeStub).to.have.been.calledWith(123, 5) + expect(onSuccessfulAssignDatasetGuestbook).to.not.have.been.called + expect(result.current.isLoadingAssignDatasetGuestbook).to.deep.equal(false) + expect(result.current.errorAssignDatasetGuestbook).to.deep.equal( + 'An error occurred while updating the dataset guestbook. Please try again.' + ) + }) +}) diff --git a/tests/component/sections/edit-dataset-terms/useGetLicenses.spec.tsx b/tests/component/sections/edit-dataset-terms/useGetLicenses.spec.tsx new file mode 100644 index 000000000..07800640a --- /dev/null +++ b/tests/component/sections/edit-dataset-terms/useGetLicenses.spec.tsx @@ -0,0 +1,122 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { useGetLicenses } from '@/sections/edit-dataset-terms/edit-license-and-terms/useGetLicenses' +import { LicenseRepository } from '@/licenses/domain/repositories/LicenseRepository' +import { License } from '@/licenses/domain/models/License' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +describe('useGetLicenses', () => { + let licenseRepository: LicenseRepository + + const mockLicenses: License[] = [ + { + id: 1, + name: 'CC0 1.0', + shortDescription: 'CC0 public domain dedication', + uri: 'http://creativecommons.org/publicdomain/zero/1.0', + active: true, + isDefault: true, + sortOrder: 0 + }, + { + id: 2, + name: 'CC BY 4.0', + shortDescription: 'Creative Commons Attribution 4.0', + uri: 'http://creativecommons.org/licenses/by/4.0', + active: true, + isDefault: false, + sortOrder: 1 + } + ] + + beforeEach(() => { + licenseRepository = {} as LicenseRepository + }) + + it('should auto fetch licenses by default', async () => { + licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) + + const { result } = renderHook(() => useGetLicenses({ licenseRepository })) + + expect(result.current.isLoadingLicenses).to.equal(true) + expect(result.current.errorLicenses).to.equal(null) + expect(result.current.licenses).to.deep.equal([]) + + await waitFor(() => { + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal(null) + expect(result.current.licenses).to.deep.equal(mockLicenses) + }) + + expect(licenseRepository.getAvailableStandardLicenses).to.have.been.calledOnce + }) + + it('should not auto fetch licenses when autoFetch is false and should fetch manually', async () => { + licenseRepository.getAvailableStandardLicenses = cy.stub().resolves(mockLicenses) + + const { result } = renderHook(() => useGetLicenses({ licenseRepository, autoFetch: false })) + + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal(null) + expect(result.current.licenses).to.deep.equal([]) + expect(licenseRepository.getAvailableStandardLicenses).to.not.have.been.called + + await act(async () => { + await result.current.fetchLicenses() + }) + + expect(licenseRepository.getAvailableStandardLicenses).to.have.been.calledOnce + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal(null) + expect(result.current.licenses).to.deep.equal(mockLicenses) + }) + + describe('Error handling', () => { + it('should set formatted error from ReadError reason without status code', async () => { + const readError = new ReadError() + licenseRepository.getAvailableStandardLicenses = cy.stub().rejects(readError) + cy.stub(JSDataverseReadErrorHandler.prototype, 'getReasonWithoutStatusCode').returns( + 'Formatted read error' + ) + cy.stub(JSDataverseReadErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback read error' + ) + + const { result } = renderHook(() => useGetLicenses({ licenseRepository })) + + await waitFor(() => { + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal('Formatted read error') + }) + }) + + it('should fall back to ReadError handler error message when reason is null', async () => { + const readError = new ReadError() + licenseRepository.getAvailableStandardLicenses = cy.stub().rejects(readError) + cy.stub(JSDataverseReadErrorHandler.prototype, 'getReasonWithoutStatusCode').returns(null) + cy.stub(JSDataverseReadErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback read error' + ) + + const { result } = renderHook(() => useGetLicenses({ licenseRepository })) + + await waitFor(() => { + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal('Fallback read error') + }) + }) + + it('should set default error message for unknown errors', async () => { + licenseRepository.getAvailableStandardLicenses = cy.stub().rejects(new Error('Unknown error')) + + const { result } = renderHook(() => useGetLicenses({ licenseRepository })) + + await waitFor(() => { + expect(result.current.isLoadingLicenses).to.equal(false) + expect(result.current.errorLicenses).to.equal( + 'Something went wrong getting the licenses. Try again later.' + ) + }) + }) + }) +}) diff --git a/tests/component/sections/edit-dataset-terms/useUpdateTermsOfAccess.spec.tsx b/tests/component/sections/edit-dataset-terms/useUpdateTermsOfAccess.spec.tsx index 00fa26755..d86e6f465 100644 --- a/tests/component/sections/edit-dataset-terms/useUpdateTermsOfAccess.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/useUpdateTermsOfAccess.spec.tsx @@ -3,6 +3,7 @@ import { useUpdateTermsOfAccess } from '@/sections/edit-dataset-terms/edit-terms import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' import { TermsOfAccess } from '@/dataset/domain/models/Dataset' import { WriteError } from '@iqss/dataverse-client-javascript' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' describe('useUpdateTermsOfAccess', () => { let datasetRepository: DatasetRepository @@ -60,9 +61,15 @@ describe('useUpdateTermsOfAccess', () => { }) describe('Error handling', () => { - it('should handle WriteError and set formatted error message', async () => { + it('should handle WriteError and set formatted error message from reason without status code', async () => { const mockWriteError = new WriteError() datasetRepository.updateTermsOfAccess = cy.stub().rejects(mockWriteError) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getReasonWithoutStatusCode').returns( + 'Formatted write error' + ) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback write error' + ) const { result } = renderHook(() => useUpdateTermsOfAccess({ @@ -78,9 +85,32 @@ describe('useUpdateTermsOfAccess', () => { expect(datasetRepository.updateTermsOfAccess).to.have.been.calledWith(123, sampleTerms) expect(onSuccessfulUpdateTermsOfAccess).to.not.have.been.called expect(result.current.isLoading).to.deep.equal(false) - expect(result.current.error).to.deep.equal( - 'An error occurred while updating the dataset terms of access. Please try again.' + expect(result.current.error).to.deep.equal('Formatted write error') + }) + + it('should fall back to handler getErrorMessage when reason without status code is null', async () => { + const mockWriteError = new WriteError() + datasetRepository.updateTermsOfAccess = cy.stub().rejects(mockWriteError) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getReasonWithoutStatusCode').returns(null) + cy.stub(JSDataverseWriteErrorHandler.prototype, 'getErrorMessage').returns( + 'Fallback write error' ) + + const { result } = renderHook(() => + useUpdateTermsOfAccess({ + datasetRepository, + onSuccessfulUpdateTermsOfAccess + }) + ) + + await act(async () => { + await result.current.handleUpdateTermsOfAccess(123, sampleTerms) + }) + + expect(datasetRepository.updateTermsOfAccess).to.have.been.calledWith(123, sampleTerms) + expect(onSuccessfulUpdateTermsOfAccess).to.not.have.been.called + expect(result.current.isLoading).to.deep.equal(false) + expect(result.current.error).to.deep.equal('Fallback write error') }) it('should handle unknown errors and set default error message', async () => { diff --git a/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx new file mode 100644 index 000000000..71d568bc9 --- /dev/null +++ b/tests/component/sections/guestbooks/useGetGuestbooksByCollectionId.spec.tsx @@ -0,0 +1,107 @@ +import { act, renderHook } from '@testing-library/react' +import { ReadError, getGuestbooksByCollectionId } from '@iqss/dataverse-client-javascript' +import { Guestbook } from '@/guestbooks/domain/models/Guestbook' +import { useGetGuestbooksByCollectionId } from '@/sections/guestbooks/useGetGuestbooksByCollectionId' + +const guestbook: Guestbook = { + id: 10, + name: 'Guestbook Test', + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + customQuestions: [], + createTime: '2026-01-01T00:00:00.000Z', + dataverseId: 1 +} + +describe('useGetGuestbooksByCollectionId', () => { + it('returns guestbooks when request succeeds', async () => { + const executeStub = cy.stub(getGuestbooksByCollectionId, 'execute').resolves([guestbook]) + + const { result } = renderHook(() => useGetGuestbooksByCollectionId({ collectionIdOrAlias: 1 })) + + await act(() => { + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(true) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal(null) + return expect(result.current.guestbooks).to.deep.equal([]) + }) + + await act(() => { + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal(null) + return expect(result.current.guestbooks).to.deep.equal([guestbook]) + }) + + cy.wrap(executeStub).should('have.been.calledOnceWith', 1) + }) + + it('does not fetch when collection id is undefined', async () => { + const executeStub = cy.stub(getGuestbooksByCollectionId, 'execute').resolves([guestbook]) + + const { result } = renderHook(() => + useGetGuestbooksByCollectionId({ collectionIdOrAlias: undefined }) + ) + + await act(() => { + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal(null) + return expect(result.current.guestbooks).to.deep.equal([]) + }) + + cy.wrap(executeStub).should('not.have.been.called') + }) + + it('resets guestbooks and sets formatted message when request fails with ReadError', async () => { + const executeStub = cy.stub(getGuestbooksByCollectionId, 'execute') + executeStub.onFirstCall().resolves([guestbook]) + executeStub.onSecondCall().rejects(new ReadError('ReadError message')) + + const { result } = renderHook(() => + useGetGuestbooksByCollectionId({ collectionIdOrAlias: 1, autoFetch: false }) + ) + + await act(async () => { + await result.current.fetchGuestbooksByCollectionId() + }) + + expect(result.current.guestbooks).to.deep.equal([guestbook]) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal(null) + + await act(async () => { + await result.current.fetchGuestbooksByCollectionId() + }) + + expect(result.current.guestbooks).to.deep.equal([]) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal('ReadError message') + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + }) + + it('resets guestbooks and sets default message when request fails with non-ReadError', async () => { + const executeStub = cy.stub(getGuestbooksByCollectionId, 'execute') + executeStub.onFirstCall().resolves([guestbook]) + executeStub.onSecondCall().rejects(new Error('unexpected')) + + const { result } = renderHook(() => + useGetGuestbooksByCollectionId({ collectionIdOrAlias: 1, autoFetch: false }) + ) + + await act(async () => { + await result.current.fetchGuestbooksByCollectionId() + }) + + expect(result.current.guestbooks).to.deep.equal([guestbook]) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal(null) + + await act(async () => { + await result.current.fetchGuestbooksByCollectionId() + }) + + expect(result.current.guestbooks).to.deep.equal([]) + expect(result.current.errorGetGuestbooksByCollectionId).to.deep.equal( + 'Something went wrong getting guestbooks by collection id. Try again later.' + ) + expect(result.current.isLoadingGuestbooksByCollectionId).to.deep.equal(false) + }) +}) diff --git a/tests/component/sections/layout/header/Header.spec.tsx b/tests/component/sections/layout/header/Header.spec.tsx index 73860e482..0c30ccb4d 100644 --- a/tests/component/sections/layout/header/Header.spec.tsx +++ b/tests/component/sections/layout/header/Header.spec.tsx @@ -76,7 +76,8 @@ describe('Header component', () => { cy.findByRole('button', { name: 'Toggle navigation' }).click() cy.findByRole('button', { name: /Add Data/i }).should('not.exist') }) - it.only('Displays the unread notifications badge', () => { + + it('Displays the unread notifications badge', () => { notificationRepository.getUnreadNotificationsCount = cy.stub().resolves(3) cy.mountAuthenticated( { cy.clock().then((clock) => clock.restore()) }) + it('should call repository only when refetchUserSession is invoked if token is missing', () => { + const getAuthenticatedStub = cy.stub().resolves(testUser) + userRepository.getAuthenticated = getAuthenticatedStub + + renderComponent({ + loginInProgress: false, + withTokenPresent: false + }) + + cy.wrap(getAuthenticatedStub).should('not.have.been.called') + + cy.findByRole('button', { name: 'Refetch User' }).click() + + cy.wrap(getAuthenticatedStub).should('have.been.calledOnce') + cy.findByText(testUser.displayName).should('exist') + }) + + it('should not fetch user when token is missing', () => { + const getAuthenticatedStub = cy.stub().resolves(testUser) + userRepository.getAuthenticated = getAuthenticatedStub + + renderComponent({ + loginInProgress: false, + withTokenPresent: false + }) + + cy.wrap(getAuthenticatedStub).should('not.have.been.called') + cy.findByText(testUser.displayName).should('not.exist') + }) + + it('should not fetch user when login is in progress even if token exists', () => { + const getAuthenticatedStub = cy.stub().resolves(testUser) + userRepository.getAuthenticated = getAuthenticatedStub + + renderComponent({ + loginInProgress: true, + withTokenPresent: true + }) + + cy.wrap(getAuthenticatedStub).should('not.have.been.called') + cy.findByText(testUser.displayName).should('not.exist') + }) + it('should detect BEARER_TOKEN_IS_VALID_BUT_NOT_LINKED_MESSAGE and redirect to sign up page', () => { userRepository.getAuthenticated = cy .stub() @@ -126,24 +169,42 @@ describe('SessionProvider', () => { }) it('should detect any other ReadError instances', () => { - userRepository.getAuthenticated = cy.stub().rejects(new ReadError()) + userRepository.getAuthenticated = cy.stub().callsFake(() => { + return Cypress.Promise.delay(DELAYED_TIME).then(() => { + throw new ReadError() + }) + }) renderComponent({ loginInProgress: false, withTokenPresent: true }) + cy.clock() + cy.findByText('Loading...').should('exist') + cy.tick(DELAYED_TIME) cy.findByText('There was an error when reading the resource.').should('exist') + cy.findByText('Loading...').should('not.exist') + cy.clock().then((clock) => clock.restore()) }) it('should show default error message when error is not a ReadError', () => { - userRepository.getAuthenticated = cy.stub().rejects(new Error('Some error')) + userRepository.getAuthenticated = cy.stub().callsFake(() => { + return Cypress.Promise.delay(DELAYED_TIME).then(() => { + throw new Error('Some error') + }) + }) renderComponent({ loginInProgress: false, withTokenPresent: true }) + cy.clock() + cy.findByText('Loading...').should('exist') + cy.tick(DELAYED_TIME) cy.findByText('An unexpected error occurred while getting the user.').should('exist') + cy.findByText('Loading...').should('not.exist') + cy.clock().then((clock) => clock.restore()) }) }) diff --git a/tests/e2e-integration/shared/guestbooks/GuestbookHelper.ts b/tests/e2e-integration/shared/guestbooks/GuestbookHelper.ts new file mode 100644 index 000000000..c26ccb00b --- /dev/null +++ b/tests/e2e-integration/shared/guestbooks/GuestbookHelper.ts @@ -0,0 +1,61 @@ +import { DataverseApiHelper } from '../DataverseApiHelper' +import { ROOT_COLLECTION_ALIAS } from '../collection/ROOT_COLLECTION_ALIAS' + +interface GuestbookResponse { + id: number + name: string +} + +const defaultGuestbookPayload = { + enabled: true, + nameRequired: true, + emailRequired: true, + institutionRequired: false, + positionRequired: false, + email: '', + institution: '', + position: '', + customQuestions: [] +} + +export class GuestbookHelper extends DataverseApiHelper { + static async create( + name: string, + collectionIdOrAlias: string = ROOT_COLLECTION_ALIAS + ): Promise { + await this.request( + `/guestbooks/${collectionIdOrAlias}`, + 'POST', + { + ...defaultGuestbookPayload, + name + }, + 'application/json' + ) + } + + static async getByCollectionId( + collectionIdOrAlias: string = ROOT_COLLECTION_ALIAS + ): Promise { + return this.request(`/guestbooks/${collectionIdOrAlias}/list`, 'GET') + } + + static async createAndGetByName( + name: string, + collectionIdOrAlias: string = ROOT_COLLECTION_ALIAS + ): Promise { + await this.create(name, collectionIdOrAlias) + const guestbooks = await this.getByCollectionId(collectionIdOrAlias) + const createdGuestbook = guestbooks.find((guestbook) => guestbook.name === name) + + if (!createdGuestbook) { + throw new Error(`Guestbook "${name}" was not found after creation`) + } + + return createdGuestbook + } + + static async assignToDataset(datasetId: number, guestbookId: number): Promise { + await this.request(`/datasets/${datasetId}/guestbook`, 'PUT', `${guestbookId}`, 'text/plain') + } +}
+ + ) + }} + /> +
+ {t('termsTab.guestbookDescription')} +
{t('preview.description')}