From 8fb9658bc5c8c7fa0031c64bd032c4d7c077e67b Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Fri, 27 Feb 2026 18:04:02 -0500 Subject: [PATCH 01/10] feat: view guestbook and download file with guestbook --- package-lock.json | 8 +- package.json | 2 +- public/locales/en/dataset.json | 15 +- public/locales/en/file.json | 12 + public/locales/en/files.json | 12 + public/locales/en/guestbooks.json | 33 ++ public/locales/es/dataset.json | 1 + public/locales/es/file.json | 12 + public/locales/es/files.json | 12 + src/dataset/domain/models/Dataset.ts | 9 +- .../infrastructure/mappers/JSDatasetMapper.ts | 3 +- src/files/domain/models/File.ts | 1 + .../infrastructure/mappers/JSFileMapper.ts | 1 + src/guestbooks/domain/models/Guestbook.ts | 28 ++ .../repositories/GuestbookRepository.ts | 5 + .../domain/useCases/getGuestbook.ts | 9 + .../GuestbookJSDataverseRepository.ts | 9 + src/sections/dataset/Dataset.tsx | 1 + .../GuestbookAppliedForm.tsx | 201 ++++++++++ .../GuestbookAppliedModal.module.scss | 40 ++ .../GuestbookAppliedModal.tsx | 378 ++++++++++++++++++ .../useSubmitGuestbookForDatafileDownload.ts | 65 +++ .../dataset-guestbook/DatasetGuestbook.tsx | 77 ++++ .../dataset-guestbook/useGetGuestbookById.ts | 62 +++ .../dataset-terms/DatasetTerms.module.scss | 9 + .../dataset/dataset-terms/DatasetTerms.tsx | 21 +- .../edit-dataset-terms/EditDatasetTerms.tsx | 21 +- .../EditDatasetTermsHelper.ts | 7 +- .../edit-guest-book/EditGuestBook.module.scss | 30 -- .../edit-guest-book/EditGuestBook.tsx | 75 ---- .../edit-guestbook/EditGuestbook.module.scss | 36 ++ .../edit-guestbook/EditGuestbook.tsx | 195 +++++++++ .../useAssignDatasetGuestbook.tsx | 51 +++ src/sections/file/File.tsx | 1 + .../access-file-menu/AccessFileMenu.tsx | 4 + .../access-file-menu/FileDownloadOptions.tsx | 8 + .../FileNonTabularDownloadOptions.tsx | 42 +- .../FileTabularDownloadOptions.tsx | 48 ++- .../preview-modal/PreviewGuestbookModal.tsx | 88 ++++ .../useGetGuestbooksByCollectionId.tsx | 68 ++++ .../EditDatasetTerms.stories.tsx | 4 +- .../dataset/domain/models/DatasetMother.ts | 3 +- .../mappers/JSDatasetMapper.spec.ts | 5 + .../EditDatasetTerms.spec.tsx | 4 +- 44 files changed, 1565 insertions(+), 151 deletions(-) create mode 100644 public/locales/en/guestbooks.json create mode 100644 src/guestbooks/domain/models/Guestbook.ts create mode 100644 src/guestbooks/domain/repositories/GuestbookRepository.ts create mode 100644 src/guestbooks/domain/useCases/getGuestbook.ts create mode 100644 src/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository.ts create mode 100644 src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedForm.tsx create mode 100644 src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.module.scss create mode 100644 src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx create mode 100644 src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts create mode 100644 src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx create mode 100644 src/sections/dataset/dataset-guestbook/useGetGuestbookById.ts delete mode 100644 src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.module.scss delete mode 100644 src/sections/edit-dataset-terms/edit-guest-book/EditGuestBook.tsx create mode 100644 src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.module.scss create mode 100644 src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx create mode 100644 src/sections/edit-dataset-terms/edit-guestbook/useAssignDatasetGuestbook.tsx create mode 100644 src/sections/guestbooks/preview-modal/PreviewGuestbookModal.tsx create mode 100644 src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx 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..468d0dfec 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,9 @@ "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.", "previewButton": "Preview Guestbook" }, "unsavedChangesModal": { @@ -373,6 +377,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..f17c00d95 --- /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" + } + } + } + } +} \ No newline at end of file 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/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/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..56cc41708 100644 --- a/src/files/domain/models/File.ts +++ b/src/files/domain/models/File.ts @@ -9,6 +9,7 @@ import { FileVersionSummarySubset } from './FileVersionSummaryInfo' export interface File { id: number datasetPersistentId: string + guestbookId?: number name: string access: FileAccess datasetVersion: DatasetVersion diff --git a/src/files/infrastructure/mappers/JSFileMapper.ts b/src/files/infrastructure/mappers/JSFileMapper.ts index 7b1610f17..47ebe5d33 100644 --- a/src/files/infrastructure/mappers/JSFileMapper.ts +++ b/src/files/infrastructure/mappers/JSFileMapper.ts @@ -62,6 +62,7 @@ export class JSFileMapper { return { id: this.toFileId(jsFile.id), datasetPersistentId: jsDataset.persistentId, + guestbookId: jsDataset.guestbookId, 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..4852a3ca8 --- /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 { + async getGuestbook(guestbookId: number): Promise { + return (await getGuestbook.execute(guestbookId)) as Guestbook + } +} diff --git a/src/sections/dataset/Dataset.tsx b/src/sections/dataset/Dataset.tsx index 2097aa8cd..557345818 100644 --- a/src/sections/dataset/Dataset.tsx +++ b/src/sections/dataset/Dataset.tsx @@ -227,6 +227,7 @@ export function Dataset({ datasetPersistentId={dataset.persistentId} datasetVersion={dataset.version} canUpdateDataset={canUpdateDataset} + hasGuestbook={dataset.guestbookId !== undefined} /> 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..ccb2fd12c --- /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,201 @@ +import { useMemo } from 'react' +import { Col, Form, Row } from '@iqss/dataverse-design-system' +import { Trans, useTranslation } from 'react-i18next' +import { Guestbook, GuestbookCustomQuestion } from '@/guestbooks/domain/models/Guestbook' +import { DatasetLicense } from '@/dataset/domain/models/Dataset' +import { Validator as validator } from '@/shared/helpers/Validator' +import styles from './GuestbookAppliedModal.module.scss' + +interface GuestbookAppliedFormProps { + license?: DatasetLicense + guestbook?: Guestbook + formValues: Record + hasAttemptedAccept: boolean + accountFieldErrors: Record + accountFieldKeys: string[] + 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, + guestbook, + formValues, + hasAttemptedAccept, + accountFieldErrors, + accountFieldKeys, + 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) + .sort((first, second) => first.displayOrder - second.displayOrder), + [guestbook?.customQuestions] + ) + + 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') { + const sortedOptionValues = [...(question.optionValues ?? [])].sort( + (first, second) => first.displayOrder - second.displayOrder + ) + + return ( + onFieldChange(fieldName, (event.target as HTMLSelectElement).value)} + aria-required={isRequired}> + + ))} + + ) + } + + return ( + onFieldChange(fieldName, (event.target as HTMLInputElement).value)} + aria-required={isRequired} + /> + ) + } + + return ( +
+ + + + {tDataset('license.title')} + + + +

+ + ) + }} + /> +

+ {license?.iconUri && ( + {`${tDataset('license.altTextPrefix')}${license.name}`} + )} + {license && ( + + {license.name} + + )} + +
+ + {accountFieldKeys.map((accountFieldKey) => { + const fieldLabel = tGuestbooks(`create.fields.dataCollected.options.${accountFieldKey}`) + const isRequired = isAccountFieldRequired(accountFieldKey) + const hasError = + (hasAttemptedAccept || accountFieldKey === 'email') && + accountFieldErrors[accountFieldKey] !== null + + return ( + + + + {fieldLabel} + {isRequired && *} + + + + + onFieldChange(accountFieldKey, (event.target as HTMLInputElement).value) + } + isInvalid={hasError} + aria-required={isRequired} + /> + {hasError && ( + + {accountFieldErrors[accountFieldKey]} + + )} + + + ) + })} + + {customQuestions.length > 0 && ( + + + + {tFiles('actions.optionsMenu.guestbookAppliedModal.additionalQuestions')} + + + + {customQuestions.map((question, index) => ( +
+ + {renderCustomQuestionField(question, index)} +
+ ))} + +
+ )} +
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.module.scss b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.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/GuestbookAppliedModal.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/GuestbookAppliedModal.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx new file mode 100644 index 000000000..9bbff3abc --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx @@ -0,0 +1,378 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Button, Modal, Spinner } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' +import { GuestbookJSDataverseRepository } from '@/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository' +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 { useSubmitGuestbookForDatafileDownload } from './useSubmitGuestbookForDatafileDownload' + +interface GuestbookAppliedModalProps { + fileId: number | string + guestbookId?: number + show: boolean + handleClose: () => void +} + +const guestbookRepository = new GuestbookJSDataverseRepository() +type GuestbookFormValues = Record +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +export function GuestbookAppliedModal({ + fileId, + guestbookId, + show, + handleClose +}: GuestbookAppliedModalProps) { + const { t: tFiles } = useTranslation('files') + const { t: tDataset } = useTranslation('dataset') + const { t: tGuestbooks } = useTranslation('guestbooks') + const { dataset } = useDataset() + const { user } = useSession() + const [formValues, setFormValues] = useState({}) + const [hasAttemptedAccept, setHasAttemptedAccept] = useState(false) + const [errorDownloadSignedUrlFile, setErrorDownloadSignedUrlFile] = useState(null) + const { + isSubmittingGuestbook, + errorSubmitGuestbook, + handleSubmitGuestbookForDatafileDownload, + resetSubmitGuestbookForDatafileDownloadState + } = useSubmitGuestbookForDatafileDownload() + + const { guestbook, isLoadingGuestbook, errorGetGuestbook } = useGetGuestbookById({ + guestbookRepository, + guestbookId: guestbookId ?? dataset?.guestbookId + }) + + const accountFieldLabels = useMemo(() => { + const options = tGuestbooks('create.fields.dataCollected.options', { + returnObjects: true + }) + + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + return {} + } + + return Object.entries(options).reduce>((labels, [key, value]) => { + if (typeof value === 'string') { + labels[key] = value + } + return labels + }, {}) + }, [tGuestbooks]) + + const accountFieldKeys = useMemo(() => Object.keys(accountFieldLabels), [accountFieldLabels]) + const prefilledAccountFieldValues = useMemo(() => { + if (!user) { + return {} + } + + const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() || user.displayName + + return accountFieldKeys.reduce((prefilledValues, fieldName) => { + if (fieldName === 'name') { + prefilledValues[fieldName] = fullName + } + 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) + .sort((first, second) => first.displayOrder - second.displayOrder), + [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 getFilenameFromContentDisposition = (contentDispositionHeader: string | null): string => { + if (!contentDispositionHeader) { + return `datafile-${fileId}` + } + + const utf8FileNameMatch = contentDispositionHeader.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8FileNameMatch?.[1]) { + try { + return decodeURIComponent(utf8FileNameMatch[1]) + } catch { + return utf8FileNameMatch[1] + } + } + + const basicFileNameMatch = contentDispositionHeader.match(/filename="?([^"]+)"?/i) + if (basicFileNameMatch?.[1]) { + return basicFileNameMatch[1] + } + + return `datafile-${fileId}` + } + + const triggerDirectDownload = async (signedUrl: string) => { + const response = await fetch(buildSignedUrl(signedUrl), { + // signed URLs do not require cookies/tokens and may redirect to cross-origin storage (e.g. S3), + // where credentialed CORS requests are rejected. + credentials: 'omit' + }) + + if (!response.ok) { + let errorMessage: string | undefined + + try { + const errorPayload = (await response.json()) as { + message?: string + data?: { message?: string } + } + if (errorPayload.message) { + errorMessage = errorPayload.message + } else if (errorPayload.data?.message) { + errorMessage = errorPayload.data.message + } + } catch { + // fallback message below + } + + throw new Error( + errorMessage ?? tFiles('actions.optionsMenu.guestbookAppliedModal.downloadError') + ) + } + + const blob = await response.blob() + const objectURL = URL.createObjectURL(blob) + const downloadLink = document.createElement('a') + + downloadLink.href = objectURL + downloadLink.download = getFilenameFromContentDisposition( + response.headers.get('content-disposition') + ) + downloadLink.style.display = 'none' + + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) + URL.revokeObjectURL(objectURL) + } + + const clearModalErrors = () => { + setErrorDownloadSignedUrlFile(null) + resetSubmitGuestbookForDatafileDownloadState() + } + + useEffect(() => { + if (show) { + setFormValues(prefilledAccountFieldValues) + setHasAttemptedAccept(false) + setErrorDownloadSignedUrlFile(null) + resetSubmitGuestbookForDatafileDownloadState() + } + }, [show, prefilledAccountFieldValues, resetSubmitGuestbookForDatafileDownloadState]) + + const handleModalClose = () => { + clearModalErrors() + handleClose() + } + + const updateFieldValue = (fieldName: string, value: string) => { + setFormValues((current) => ({ + ...current, + [fieldName]: value + })) + } + + const handleSubmit = async () => { + setHasAttemptedAccept(true) + setErrorDownloadSignedUrlFile(null) + + if (hasAccountFieldErrors || !guestbook) { + return + } + + const answers = buildGuestbookAnswers() + const signedUrl = await handleSubmitGuestbookForDatafileDownload(fileId, answers) + if (signedUrl) { + handleModalClose() + void triggerDirectDownload(signedUrl).catch(() => undefined) + } + } + + return ( + + + {tDataset('termsTab.licenseTitle')} + + + {isLoadingGuestbook ? ( + + ) : ( + <> + {errorGetGuestbook && {errorGetGuestbook}} + {errorSubmitGuestbook && {errorSubmitGuestbook}} + {errorDownloadSignedUrlFile && ( + {errorDownloadSignedUrlFile} + )} + + + )} + + + + + + + ) +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts new file mode 100644 index 000000000..cf38866ce --- /dev/null +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts @@ -0,0 +1,65 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { WriteError, submitGuestbookForDatafileDownload } from '@iqss/dataverse-client-javascript' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' + +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +interface UseSubmitGuestbookForDatafileDownloadReturn { + isSubmittingGuestbook: boolean + errorSubmitGuestbook: string | null + handleSubmitGuestbookForDatafileDownload: ( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ) => Promise + resetSubmitGuestbookForDatafileDownloadState: () => void +} + +export const useSubmitGuestbookForDatafileDownload = + (): UseSubmitGuestbookForDatafileDownloadReturn => { + const { t } = useTranslation('files') + const [isSubmittingGuestbook, setIsSubmittingGuestbook] = useState(false) + const [errorSubmitGuestbook, setErrorSubmitGuestbook] = useState(null) + + const handleSubmitGuestbookForDatafileDownload = useCallback( + async ( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ): Promise => { + setIsSubmittingGuestbook(true) + setErrorSubmitGuestbook(null) + + try { + return await submitGuestbookForDatafileDownload.execute(fileId, { + guestbookResponse: { answers } + }) + } catch (err) { + if (err instanceof WriteError) { + const errorHandler = new JSDataverseWriteErrorHandler(err) + const formattedError = + errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() + setErrorSubmitGuestbook(formattedError) + } else { + setErrorSubmitGuestbook(t('actions.optionsMenu.guestbookAppliedModal.submitError')) + } + } finally { + setIsSubmittingGuestbook(false) + } + + return undefined + }, + [t] + ) + + const resetSubmitGuestbookForDatafileDownloadState = useCallback(() => { + setIsSubmittingGuestbook(false) + setErrorSubmitGuestbook(null) + }, []) + + return { + isSubmittingGuestbook, + errorSubmitGuestbook, + handleSubmitGuestbookForDatafileDownload, + resetSubmitGuestbookForDatafileDownloadState + } + } diff --git a/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx new file mode 100644 index 000000000..b910338af --- /dev/null +++ b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx @@ -0,0 +1,77 @@ +import { 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 { PreviewGuestbookModal } from '@/sections/guestbooks/preview-modal/PreviewGuestbookModal' +import { useDataset } from '@/sections/dataset/DatasetContext' +import styles from '@/sections/dataset/dataset-terms/DatasetTerms.module.scss' + +const guestbookRepository = new GuestbookJSDataverseRepository() + +export const DatasetGuestbook = () => { + const { t } = useTranslation('dataset') + const { dataset } = useDataset() + const [showPreview, setShowPreview] = useState(false) + const datasetHasGuestbook = dataset?.guestbookId !== undefined + const { guestbook, isLoadingGuestbook } = useGetGuestbookById({ + guestbookRepository, + guestbookId: dataset?.guestbookId + }) + const hasGuestbook = guestbook !== undefined + + return ( + <> + + + {t('termsTab.guestbookTitle')} + + + + {!datasetHasGuestbook ? ( +

+ + ) + }} + /> +

+ ) : ( + <> +

{t('termsTab.guestbookDescription')}

+
+ {isLoadingGuestbook ? ( + + ) : ( + <> + {guestbook?.name ?? '-'} + {hasGuestbook && ( + + )} + + )} +
+ + )} + +
+ {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..37401ed06 100644 --- a/src/sections/dataset/dataset-terms/DatasetTerms.tsx +++ b/src/sections/dataset/dataset-terms/DatasetTerms.tsx @@ -14,6 +14,7 @@ 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' interface DatasetTermsProps { license: DatasetLicense | undefined @@ -22,6 +23,7 @@ interface DatasetTermsProps { datasetPersistentId: string datasetVersion: DatasetVersion canUpdateDataset?: boolean + hasGuestbook?: boolean } export function DatasetTerms({ @@ -30,7 +32,8 @@ export function DatasetTerms({ filesRepository, datasetPersistentId, datasetVersion, - canUpdateDataset + canUpdateDataset, + hasGuestbook = false }: DatasetTermsProps) { const { t } = useTranslation('dataset') const { filesCountInfo, isLoading } = useGetFilesCountInfo({ @@ -51,10 +54,18 @@ export function DatasetTerms({ return } + const defaultActiveKeys = ['0'] + if (displayTermsOfAccess) { + defaultActiveKeys.push('1') + } + if (hasGuestbook) { + defaultActiveKeys.push('2') + } + return ( <> - + {t('termsTab.licenseTitle')} @@ -74,6 +85,12 @@ 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..64e1c08ae 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' @@ -10,6 +10,11 @@ export class EditDatasetTermsHelper { public static defineSelectedTabKey(searchParams: URLSearchParams): EditDatasetTermsTabKey { const tabValue = searchParams.get(this.EDIT_DATASET_TERMS_TAB_QUERY_KEY) + // Backward compatibility for older URLs + if (tabValue === 'guestBook') { + return this.EDIT_DATASET_TERMS_TABS_KEYS.guestbook + } + return ( this.EDIT_DATASET_TERMS_TABS_KEYS[ tabValue as keyof typeof this.EDIT_DATASET_TERMS_TABS_KEYS 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')} - - - - - - - - - -
- - -
-
- - -
- ) -} 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..974a86c5e --- /dev/null +++ b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.module.scss @@ -0,0 +1,36 @@ +.edit-guest-book { + + .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..8f826f522 --- /dev/null +++ b/src/sections/edit-dataset-terms/edit-guestbook/EditGuestbook.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useState } from 'react' +import { Alert, Button, Col, Form, Row, Spinner } from '@iqss/dataverse-design-system' +import { 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 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')} + + + {t('editTerms.guestbook.description')} +
+ {isLoadingGuestbooksByCollectionId && ( +
+ +
+ )} + + {errorGetGuestbooksByCollectionId && ( + {errorGetGuestbooksByCollectionId} + )} + {errorAssignDatasetGuestbook && ( + {errorAssignDatasetGuestbook} + )} + + {!isLoadingGuestbooksByCollectionId && + !errorGetGuestbooksByCollectionId && + guestbooks.map((guestbook) => ( +
+ setSelectedGuestbookId(guestbook.id)} + label={guestbook.name} + /> + + +
+ ))} +
+ +
+ +
+ + +
+
+ + {previewGuestbook && ( + 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..b64d48f20 100644 --- a/src/sections/file/File.tsx +++ b/src/sections/file/File.tsx @@ -148,6 +148,7 @@ export function File({ {isTabular ? ( ) : ( ) => { + if (!hasGuestbook || downloadDisabled) { + return + } + + event.preventDefault() + setShowGuestbookAppliedModal(true) + } return ( - - {type.displayFormatIsUnknown - ? t('actions.accessFileMenu.downloadOptions.options.original') - : type.toDisplayFormat()} - + <> + + {type.displayFormatIsUnknown + ? t('actions.accessFileMenu.downloadOptions.options.original') + : type.toDisplayFormat()} + + setShowGuestbookAppliedModal(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..8b6c09c2a 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,21 @@ 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 { GuestbookAppliedModal } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal' +import { MouseEvent, useState } from 'react' import FileTypeToFriendlyTypeMap from '../../../../files/domain/models/FileTypeToFriendlyTypeMap' interface FileTabularDownloadOptionsProps { + fileId: number + guestbookId?: number type: FileType ingestInProgress: boolean downloadUrls: FileDownloadUrls } export function FileTabularDownloadOptions({ + fileId, + guestbookId, type, ingestInProgress, downloadUrls @@ -18,22 +24,54 @@ 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 [showGuestbookAppliedModal, setShowGuestbookAppliedModal] = useState(false) + + const handleDownloadClick = (event: MouseEvent) => { + if (!hasGuestbook || downloadDisabled) { + return + } + + event.preventDefault() + setShowGuestbookAppliedModal(true) + } + + const handleCloseGuestbookModal = () => { + setShowGuestbookAppliedModal(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..013afdbda --- /dev/null +++ b/src/sections/guestbooks/preview-modal/PreviewGuestbookModal.tsx @@ -0,0 +1,88 @@ +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')}) +
  • + ))} +
+ {t('preview.customQuestionsLabel')} +
    + {(guestbook?.customQuestions ?? []).map((question, index) => ( +
  • + {question.question} ( + {question.required ? t('preview.required') : t('preview.optional')}) +
  • + ))} +
+ +
+
+ + + +
+ ) +} diff --git a/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx new file mode 100644 index 000000000..072865e22 --- /dev/null +++ b/src/sections/guestbooks/useGetGuestbooksByCollectionId.tsx @@ -0,0 +1,68 @@ +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 { + await getGuestbooksByCollectionId + .execute(collectionIdOrAlias) + .then((guestbooks: Guestbook[]) => { + setGuestbooks(guestbooks) + }) + } 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/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/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..1eed9523a 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, diff --git a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx index 938df0b50..d3339ae44 100644 --- a/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx +++ b/tests/component/sections/edit-dataset-terms/EditDatasetTerms.spec.tsx @@ -84,7 +84,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 +115,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', () => { From 1ac21f6fdc9356a339e4d9e1207ba99e2c1d5dba Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Tue, 3 Mar 2026 17:40:30 -0500 Subject: [PATCH 02/10] feat: add submit multiple files with guestbook --- public/locales/en/guestbooks.json | 22 +-- .../domain/repositories/AccessRepository.ts | 12 ++ .../submitGuestbookForDatafileDownload.ts | 9 + .../submitGuestbookForDatafilesDownload.ts | 9 + .../useSubmitGuestbookForDatafileDownload.ts | 71 +++++++ .../useSubmitGuestbookForDatafilesDownload.ts | 71 +++++++ .../AccessJSDataverseRepository.ts | 28 +++ src/sections/dataset/Dataset.tsx | 1 - .../download-files/DownloadFilesButton.tsx | 57 +++++- .../GuestbookAppliedModal.tsx | 64 ++++-- .../useSubmitGuestbookForDatafileDownload.ts | 65 ------ .../dataset-guestbook/DatasetGuestbook.tsx | 25 ++- .../dataset/dataset-terms/DatasetTerms.tsx | 18 +- .../edit-guestbook/EditGuestbook.module.scss | 1 - ...ubmitGuestbookForDatafileDownload.spec.tsx | 100 ++++++++++ ...bmitGuestbookForDatafilesDownload.spec.tsx | 103 ++++++++++ .../mappers/JSDatasetMapper.spec.ts | 28 +++ .../sections/dataset/Dataset.spec.tsx | 1 + .../DownloadFilesButton.spec.tsx | 52 +++++ .../guestbook/GuestbookAppliedModal.spec.tsx | 185 ++++++++++++++++++ .../guestbook/useGetGuestbookById.spec.tsx | 84 ++++++++ .../dataset-terms/DatasetTerms.spec.tsx | 73 +++++++ .../shared/guestbooks/GuestbookHelper.ts | 61 ++++++ 23 files changed, 1027 insertions(+), 113 deletions(-) create mode 100644 src/access/domain/repositories/AccessRepository.ts create mode 100644 src/access/domain/useCases/submitGuestbookForDatafileDownload.ts create mode 100644 src/access/domain/useCases/submitGuestbookForDatafilesDownload.ts create mode 100644 src/access/hooks/useSubmitGuestbookForDatafileDownload.ts create mode 100644 src/access/hooks/useSubmitGuestbookForDatafilesDownload.ts create mode 100644 src/access/infrastructure/repositories/AccessJSDataverseRepository.ts delete mode 100644 src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts create mode 100644 tests/component/access/domain/hooks/useSubmitGuestbookForDatafileDownload.spec.tsx create mode 100644 tests/component/access/domain/hooks/useSubmitGuestbookForDatafilesDownload.spec.tsx create mode 100644 tests/component/sections/dataset/dataset-files/guestbook/GuestbookAppliedModal.spec.tsx create mode 100644 tests/component/sections/dataset/dataset-files/guestbook/useGetGuestbookById.spec.tsx create mode 100644 tests/e2e-integration/shared/guestbooks/GuestbookHelper.ts diff --git a/public/locales/en/guestbooks.json b/public/locales/en/guestbooks.json index f17c00d95..5670d8bc9 100644 --- a/public/locales/en/guestbooks.json +++ b/public/locales/en/guestbooks.json @@ -16,18 +16,18 @@ "required": "Required", "optional": "Optional" }, - "create":{ + "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" - } + "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" } + } } } -} \ No newline at end of file +} diff --git a/src/access/domain/repositories/AccessRepository.ts b/src/access/domain/repositories/AccessRepository.ts new file mode 100644 index 000000000..526b7867a --- /dev/null +++ b/src/access/domain/repositories/AccessRepository.ts @@ -0,0 +1,12 @@ +export type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +export interface AccessRepository { + 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/hooks/useSubmitGuestbookForDatafileDownload.ts b/src/access/hooks/useSubmitGuestbookForDatafileDownload.ts new file mode 100644 index 000000000..a6de1840c --- /dev/null +++ b/src/access/hooks/useSubmitGuestbookForDatafileDownload.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' +import { submitGuestbookForDatafileDownload } from '@/access/domain/useCases/submitGuestbookForDatafileDownload' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { AccessJSDataverseRepository } from '@/access/infrastructure/repositories/AccessJSDataverseRepository' + +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +interface UseSubmitGuestbookForDatafileDownloadReturn { + isSubmittingGuestbook: boolean + errorSubmitGuestbook: string | null + handleSubmitGuestbookForDatafileDownload: ( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ) => Promise + resetSubmitGuestbookForDatafileDownloadState: () => void +} + +interface UseSubmitGuestbookForDatafileDownloadProps { + accessRepository?: AccessRepository +} + +export const useSubmitGuestbookForDatafileDownload = ({ + accessRepository = new AccessJSDataverseRepository() +}: UseSubmitGuestbookForDatafileDownloadProps = {}): UseSubmitGuestbookForDatafileDownloadReturn => { + const { t } = useTranslation('files') + const [isSubmittingGuestbook, setIsSubmittingGuestbook] = useState(false) + const [errorSubmitGuestbook, setErrorSubmitGuestbook] = useState(null) + + const handleSubmitGuestbookForDatafileDownload = useCallback( + async ( + fileId: number | string, + answers: GuestbookResponseAnswer[] + ): Promise => { + setIsSubmittingGuestbook(true) + setErrorSubmitGuestbook(null) + + try { + return await submitGuestbookForDatafileDownload(accessRepository, fileId, answers) + } catch (err) { + if (err instanceof WriteError) { + const errorHandler = new JSDataverseWriteErrorHandler(err) + const formattedError = + errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() + setErrorSubmitGuestbook(formattedError) + } else { + setErrorSubmitGuestbook(t('actions.optionsMenu.guestbookAppliedModal.submitError')) + } + } finally { + setIsSubmittingGuestbook(false) + } + + return undefined + }, + [accessRepository, t] + ) + + const resetSubmitGuestbookForDatafileDownloadState = useCallback(() => { + setIsSubmittingGuestbook(false) + setErrorSubmitGuestbook(null) + }, []) + + return { + isSubmittingGuestbook, + errorSubmitGuestbook, + handleSubmitGuestbookForDatafileDownload, + resetSubmitGuestbookForDatafileDownloadState + } +} diff --git a/src/access/hooks/useSubmitGuestbookForDatafilesDownload.ts b/src/access/hooks/useSubmitGuestbookForDatafilesDownload.ts new file mode 100644 index 000000000..0498c8a95 --- /dev/null +++ b/src/access/hooks/useSubmitGuestbookForDatafilesDownload.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' +import { submitGuestbookForDatafilesDownload } from '@/access/domain/useCases/submitGuestbookForDatafilesDownload' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { AccessJSDataverseRepository } from '@/access/infrastructure/repositories/AccessJSDataverseRepository' + +type GuestbookResponseAnswer = { id: number | string; value: string | string[] } + +interface UseSubmitGuestbookForDatafilesDownloadReturn { + isSubmittingGuestbook: boolean + errorSubmitGuestbook: string | null + handleSubmitGuestbookForDatafilesDownload: ( + fileIds: Array, + answers: GuestbookResponseAnswer[] + ) => Promise + resetSubmitGuestbookForDatafilesDownloadState: () => void +} + +interface UseSubmitGuestbookForDatafilesDownloadProps { + accessRepository?: AccessRepository +} + +export const useSubmitGuestbookForDatafilesDownload = ({ + accessRepository = new AccessJSDataverseRepository() +}: UseSubmitGuestbookForDatafilesDownloadProps = {}): UseSubmitGuestbookForDatafilesDownloadReturn => { + const { t } = useTranslation('files') + const [isSubmittingGuestbook, setIsSubmittingGuestbook] = useState(false) + const [errorSubmitGuestbook, setErrorSubmitGuestbook] = useState(null) + + const handleSubmitGuestbookForDatafilesDownload = useCallback( + async ( + fileIds: Array, + answers: GuestbookResponseAnswer[] + ): Promise => { + setIsSubmittingGuestbook(true) + setErrorSubmitGuestbook(null) + + try { + return await submitGuestbookForDatafilesDownload(accessRepository, fileIds, answers) + } catch (err) { + if (err instanceof WriteError) { + const errorHandler = new JSDataverseWriteErrorHandler(err) + const formattedError = + errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() + setErrorSubmitGuestbook(formattedError) + } else { + setErrorSubmitGuestbook(t('actions.optionsMenu.guestbookAppliedModal.submitError')) + } + } finally { + setIsSubmittingGuestbook(false) + } + + return undefined + }, + [accessRepository, t] + ) + + const resetSubmitGuestbookForDatafilesDownloadState = useCallback(() => { + setIsSubmittingGuestbook(false) + setErrorSubmitGuestbook(null) + }, []) + + return { + isSubmittingGuestbook, + errorSubmitGuestbook, + handleSubmitGuestbookForDatafilesDownload, + resetSubmitGuestbookForDatafilesDownloadState + } +} diff --git a/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts b/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts new file mode 100644 index 000000000..eccbb59cd --- /dev/null +++ b/src/access/infrastructure/repositories/AccessJSDataverseRepository.ts @@ -0,0 +1,28 @@ +import { + submitGuestbookForDatafileDownload as submitGuestbookForDatafileDownloadJSDv, + submitGuestbookForDatafilesDownload as submitGuestbookForDatafilesDownloadJSDv +} from '@iqss/dataverse-client-javascript' +import { + AccessRepository, + GuestbookResponseAnswer +} from '@/access/domain/repositories/AccessRepository' + +export class AccessJSDataverseRepository implements AccessRepository { + 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/sections/dataset/Dataset.tsx b/src/sections/dataset/Dataset.tsx index 557345818..2097aa8cd 100644 --- a/src/sections/dataset/Dataset.tsx +++ b/src/sections/dataset/Dataset.tsx @@ -227,7 +227,6 @@ export function Dataset({ datasetPersistentId={dataset.persistentId} datasetVersion={dataset.version} canUpdateDataset={canUpdateDataset} - hasGuestbook={dataset.guestbookId !== undefined} />
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..4bc63facc 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 { GuestbookAppliedModal } from '../file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal' interface DownloadFilesButtonProps { files: FilePreview[] @@ -24,23 +25,33 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto const { t } = useTranslation('files') const { dataset } = useDataset() const [showNoFilesSelectedModal, setShowNoFilesSelectedModal] = useState(false) + const [showGuestbookAppliedModal, setShowGuestbookAppliedModal] = 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 hasGuestbook = dataset?.guestbookId !== undefined + const onClick = (event: MouseEvent) => { if (fileSelectionCount === SELECTED_FILES_EMPTY) { event.preventDefault() setShowNoFilesSelectedModal(true) + return + } + + if (hasGuestbook) { + event.preventDefault() + setShowGuestbookAppliedModal(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 +85,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 +100,38 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto show={showNoFilesSelectedModal} handleClose={() => setShowNoFilesSelectedModal(false)} /> + setShowGuestbookAppliedModal(false)} + /> + + ) + } + + if (hasGuestbook) { + return ( + <> + + setShowNoFilesSelectedModal(false)} + /> + setShowGuestbookAppliedModal(false)} + /> ) } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx index 9bbff3abc..57be500a4 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/GuestbookAppliedModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Alert, Button, Modal, Spinner } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' import { GuestbookJSDataverseRepository } from '@/guestbooks/infrastructure/repositories/GuestbookJSDataverseRepository' import { useDataset } from '@/sections/dataset/DatasetContext' import { useGetGuestbookById } from '@/sections/dataset/dataset-guestbook/useGetGuestbookById' @@ -11,24 +12,32 @@ import { } from './GuestbookAppliedForm' import { useSession } from '@/sections/session/SessionContext' import { Guestbook, GuestbookCustomQuestion } from '@/guestbooks/domain/models/Guestbook' -import { useSubmitGuestbookForDatafileDownload } from './useSubmitGuestbookForDatafileDownload' +import { useSubmitGuestbookForDatafileDownload } from '@/access/hooks/useSubmitGuestbookForDatafileDownload' +import { useSubmitGuestbookForDatafilesDownload } from '@/access/hooks/useSubmitGuestbookForDatafilesDownload' +import { GuestbookRepository } from '@/guestbooks/domain/repositories/GuestbookRepository' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' interface GuestbookAppliedModalProps { - fileId: number | string + fileId?: number | string + fileIds?: Array guestbookId?: number show: boolean handleClose: () => void + guestbookRepository?: GuestbookRepository + accessRepository?: AccessRepository } -const guestbookRepository = new GuestbookJSDataverseRepository() type GuestbookFormValues = Record type GuestbookResponseAnswer = { id: number | string; value: string | string[] } export function GuestbookAppliedModal({ fileId, + fileIds, guestbookId, show, - handleClose + handleClose, + guestbookRepository = new GuestbookJSDataverseRepository(), + accessRepository }: GuestbookAppliedModalProps) { const { t: tFiles } = useTranslation('files') const { t: tDataset } = useTranslation('dataset') @@ -39,15 +48,20 @@ export function GuestbookAppliedModal({ const [hasAttemptedAccept, setHasAttemptedAccept] = useState(false) const [errorDownloadSignedUrlFile, setErrorDownloadSignedUrlFile] = useState(null) const { - isSubmittingGuestbook, - errorSubmitGuestbook, + isSubmittingGuestbook: isSubmittingGuestbookDatafile, + errorSubmitGuestbook: errorSubmitGuestbookDatafile, handleSubmitGuestbookForDatafileDownload, resetSubmitGuestbookForDatafileDownloadState - } = useSubmitGuestbookForDatafileDownload() - + } = useSubmitGuestbookForDatafileDownload({ accessRepository }) + const { + isSubmittingGuestbook: isSubmittingGuestbookDatafiles, + errorSubmitGuestbook: errorSubmitGuestbookDatafiles, + handleSubmitGuestbookForDatafilesDownload, + resetSubmitGuestbookForDatafilesDownloadState + } = useSubmitGuestbookForDatafilesDownload({ accessRepository }) const { guestbook, isLoadingGuestbook, errorGetGuestbook } = useGetGuestbookById({ guestbookRepository, - guestbookId: guestbookId ?? dataset?.guestbookId + guestbookId: guestbookId }) const accountFieldLabels = useMemo(() => { @@ -224,7 +238,7 @@ export function GuestbookAppliedModal({ const getFilenameFromContentDisposition = (contentDispositionHeader: string | null): string => { if (!contentDispositionHeader) { - return `datafile-${fileId}` + return fileId !== undefined ? `datafile-${fileId}` : 'files-download' } const utf8FileNameMatch = contentDispositionHeader.match(/filename\*=UTF-8''([^;]+)/i) @@ -241,7 +255,7 @@ export function GuestbookAppliedModal({ return basicFileNameMatch[1] } - return `datafile-${fileId}` + return fileId !== undefined ? `datafile-${fileId}` : 'files-download' } const triggerDirectDownload = async (signedUrl: string) => { @@ -292,16 +306,26 @@ export function GuestbookAppliedModal({ const clearModalErrors = () => { setErrorDownloadSignedUrlFile(null) resetSubmitGuestbookForDatafileDownloadState() + resetSubmitGuestbookForDatafilesDownloadState() } + const isSubmittingGuestbook = isSubmittingGuestbookDatafile || isSubmittingGuestbookDatafiles + const errorSubmitGuestbook = errorSubmitGuestbookDatafile || errorSubmitGuestbookDatafiles + useEffect(() => { if (show) { setFormValues(prefilledAccountFieldValues) setHasAttemptedAccept(false) setErrorDownloadSignedUrlFile(null) resetSubmitGuestbookForDatafileDownloadState() + resetSubmitGuestbookForDatafilesDownloadState() } - }, [show, prefilledAccountFieldValues, resetSubmitGuestbookForDatafileDownloadState]) + }, [ + show, + prefilledAccountFieldValues, + resetSubmitGuestbookForDatafileDownloadState, + resetSubmitGuestbookForDatafilesDownloadState + ]) const handleModalClose = () => { clearModalErrors() @@ -324,10 +348,22 @@ export function GuestbookAppliedModal({ } const answers = buildGuestbookAnswers() - const signedUrl = await handleSubmitGuestbookForDatafileDownload(fileId, answers) + + let signedUrl: string | undefined + + if (fileId !== undefined) { + signedUrl = await handleSubmitGuestbookForDatafileDownload(fileId, answers) + } else if (fileIds && fileIds.length > 0) { + signedUrl = await handleSubmitGuestbookForDatafilesDownload(fileIds, answers) + } else { + return + } if (signedUrl) { handleModalClose() - void triggerDirectDownload(signedUrl).catch(() => undefined) + void triggerDirectDownload(signedUrl).catch((error) => { + const fallbackMessage = tFiles('actions.optionsMenu.guestbookAppliedModal.downloadError') + toast.error(error instanceof Error ? error.message : fallbackMessage) + }) } } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts deleted file mode 100644 index cf38866ce..000000000 --- a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/useSubmitGuestbookForDatafileDownload.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { WriteError, submitGuestbookForDatafileDownload } from '@iqss/dataverse-client-javascript' -import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' - -type GuestbookResponseAnswer = { id: number | string; value: string | string[] } - -interface UseSubmitGuestbookForDatafileDownloadReturn { - isSubmittingGuestbook: boolean - errorSubmitGuestbook: string | null - handleSubmitGuestbookForDatafileDownload: ( - fileId: number | string, - answers: GuestbookResponseAnswer[] - ) => Promise - resetSubmitGuestbookForDatafileDownloadState: () => void -} - -export const useSubmitGuestbookForDatafileDownload = - (): UseSubmitGuestbookForDatafileDownloadReturn => { - const { t } = useTranslation('files') - const [isSubmittingGuestbook, setIsSubmittingGuestbook] = useState(false) - const [errorSubmitGuestbook, setErrorSubmitGuestbook] = useState(null) - - const handleSubmitGuestbookForDatafileDownload = useCallback( - async ( - fileId: number | string, - answers: GuestbookResponseAnswer[] - ): Promise => { - setIsSubmittingGuestbook(true) - setErrorSubmitGuestbook(null) - - try { - return await submitGuestbookForDatafileDownload.execute(fileId, { - guestbookResponse: { answers } - }) - } catch (err) { - if (err instanceof WriteError) { - const errorHandler = new JSDataverseWriteErrorHandler(err) - const formattedError = - errorHandler.getReasonWithoutStatusCode() ?? errorHandler.getErrorMessage() - setErrorSubmitGuestbook(formattedError) - } else { - setErrorSubmitGuestbook(t('actions.optionsMenu.guestbookAppliedModal.submitError')) - } - } finally { - setIsSubmittingGuestbook(false) - } - - return undefined - }, - [t] - ) - - const resetSubmitGuestbookForDatafileDownloadState = useCallback(() => { - setIsSubmittingGuestbook(false) - setErrorSubmitGuestbook(null) - }, []) - - return { - isSubmittingGuestbook, - errorSubmitGuestbook, - handleSubmitGuestbookForDatafileDownload, - resetSubmitGuestbookForDatafileDownloadState - } - } diff --git a/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx index b910338af..1dc6f6e77 100644 --- a/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx +++ b/src/sections/dataset/dataset-guestbook/DatasetGuestbook.tsx @@ -3,33 +3,40 @@ 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' -const guestbookRepository = new GuestbookJSDataverseRepository() +interface DatasetGuestbookProps { + guestbookRepository?: GuestbookRepository +} -export const DatasetGuestbook = () => { +export const DatasetGuestbook = ({ + guestbookRepository = new GuestbookJSDataverseRepository() +}: DatasetGuestbookProps) => { const { t } = useTranslation('dataset') const { dataset } = useDataset() const [showPreview, setShowPreview] = useState(false) const datasetHasGuestbook = dataset?.guestbookId !== undefined const { guestbook, isLoadingGuestbook } = useGetGuestbookById({ guestbookRepository, - guestbookId: dataset?.guestbookId + guestbookId: dataset?.guestbookId as number }) const hasGuestbook = guestbook !== undefined return ( <> - + {t('termsTab.guestbookTitle')} {!datasetHasGuestbook ? ( -

+

{

) : ( <> -

{t('termsTab.guestbookDescription')}

+

+ {t('termsTab.guestbookDescription')} +

{isLoadingGuestbook ? ( ) : ( <> - {guestbook?.name ?? '-'} + {guestbook?.name ?? '-'} {hasGuestbook && ( + 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} + /> + + +
+ ))} +
+ )}
@@ -177,7 +202,7 @@ export function EditGuestbook({ onPreview }: EditGuestbookProps) { selectedGuestbookId === dataset?.guestbookId || isLoadingAssignDatasetGuestbook }> - {tShared('saveChanges')} + {isLoadingAssignDatasetGuestbook ? tShared('saving') : tShared('saveChanges')} + + ) + } + + 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)} + + + ) + } + + 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')) 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/session/SessionProvider.spec.tsx b/tests/component/sections/session/SessionProvider.spec.tsx index 4abfa6afa..0199e2334 100644 --- a/tests/component/sections/session/SessionProvider.spec.tsx +++ b/tests/component/sections/session/SessionProvider.spec.tsx @@ -112,6 +112,49 @@ describe('SessionProvider', () => { 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()) }) })